GAIO (Generative AI Optimization) Audit Dashboard

Automated GAIO Performance Tracking & Optimisation

Version: 6d2a6b3 | Built: | Loaded:

0%
Initializing audit scan...

🔍 Debug Log

[Initializing...]Debug panel loaded. Waiting for page initialization...
age if (window.moneyPagePriorityData && window.moneyPagePriorityData.length > 0) { const updatedAudit = { ...saved, moneyPagePriorityData: window.moneyPagePriorityData, moneySegmentMetrics: window.moneySegmentMetrics }; safeSetLocalStorage('last_audit_results', updatedAudit); } } } else { // Function is available, proceed with rebuild window.moneyPagePriorityData = window.buildMoneyPageMetrics(topPagesForPriority, auditSchema); // Use saved moneySegmentMetrics if available (from Supabase), otherwise rebuild if (saved && saved.moneySegmentMetrics) { window.moneySegmentMetrics = saved.moneySegmentMetrics; debugLog(`✓ Using moneySegmentMetrics from saved data (Supabase)`, 'success'); } else { // Rebuild segment metrics const behaviourScoresBySegment = {}; if (savedMetrics.behaviour) { behaviourScoresBySegment.allMoney = savedMetrics.behaviour.score || 0; } if (typeof buildMoneySegmentSummary === 'function') { window.moneySegmentMetrics = buildMoneySegmentSummary(window.moneyPagePriorityData, behaviourScoresBySegment); debugLog(`✓ Rebuilt moneySegmentMetrics from moneyPagePriorityData`, 'success'); } else { debugLog(`⚠ buildMoneySegmentSummary is not available, cannot rebuild moneySegmentMetrics`, 'warn'); window.moneySegmentMetrics = null; } } debugLog(`✓ Rebuilt Money Pages Priority data from saved metrics: ${window.moneyPagePriorityData.length} pages`, 'success'); // Save the rebuilt data back to localStorage if (window.moneyPagePriorityData && window.moneyPagePriorityData.length > 0) { const updatedAudit = { ...saved, moneyPagePriorityData: window.moneyPagePriorityData, moneySegmentMetrics: window.moneySegmentMetrics }; safeSetLocalStorage('last_audit_results', updatedAudit); } } } catch (error) { debugLog(`⚠ Failed to rebuild Money Pages Priority data: ${error.message}`, 'warn'); debugLog(`Error stack: ${error.stack}`, 'error'); window.moneyPagePriorityData = []; window.moneySegmentMetrics = null; } } else { // No saved data available window.moneyPagePriorityData = window.moneyPagePriorityData || []; window.moneySegmentMetrics = window.moneySegmentMetrics || null; debugLog('⚠ No Money Pages Priority data available (will be empty until next audit)', 'warn'); debugLog(`Debug: saved=${!!saved}, savedMetrics=${!!savedMetrics}, savedMetrics.rows=${savedMetrics?.rows?.length || 0}, searchData=${!!(saved && saved.searchData || data)}`, 'warn'); } } // Update retry button visibility updateRetryButtonVisibility(schemaAudit); debugLog('=== DISPLAY DASHBOARD: Starting ===', 'info'); debugLog(`Using current audit data: schemaAudit=${schemaAudit ? 'yes' : 'no'}, localSignals=${currentLocalSignals ? 'yes' : 'no'}`, 'info'); debugLog(`Scores object keys: ${scores ? Object.keys(scores).join(', ') : 'null'}`, 'info'); debugLog(`Money Pages Metrics in scores: ${scores?.moneyPagesMetrics ? 'yes' : 'no'}`, 'info'); if (saved && saved.scores) { debugLog(`Saved scores keys: ${Object.keys(saved.scores).join(', ')}`, 'info'); debugLog(`Money Pages Metrics in saved scores: ${saved.scores.moneyPagesMetrics ? 'yes' : 'no'}`, 'info'); } // Show dashboard first (canvas elements need to be visible for Chart.js) const dashboardDiv = document.getElementById('dashboard'); if (!dashboardDiv) { debugLog('✗ Dashboard div not found', 'error'); console.error('Dashboard div not found'); return; } debugLog('✓ Dashboard div found', 'success'); dashboardDiv.style.display = 'block'; debugLog('Dashboard div displayed', 'info'); // Update trend chart description with date range const dateRange = parseInt(document.getElementById('dateRange').value) || 30; const trendDesc = document.getElementById('trendChartDescription'); if (trendDesc) { let rangeText = ''; if (dateRange === 30) rangeText = 'Last 30 Days'; else if (dateRange === 60) rangeText = 'Last 60 Days'; else if (dateRange === 90) rangeText = 'Last 90 Days'; else if (dateRange === 120) rangeText = 'Last 120 Days'; else if (dateRange === 180) rangeText = 'Last 6 Months'; else if (dateRange === 365) rangeText = 'Last 12 Months'; else if (dateRange === 540) rangeText = 'Last 18 Months'; else rangeText = `Last ${dateRange} Days`; trendDesc.textContent = `Historical performance tracking for Local Entity, Visibility, and Authority pillars over ${rangeText.toLowerCase()}.`; } const pillarNames = { localEntity: 'Local Entity', serviceArea: 'Service Area', authority: 'Authority', visibility: 'Visibility', contentSchema: 'Content / Schema' }; // Define pillar weightings and order by weight (highest to lowest) const pillarWeights = { authority: 0.30, // 30% - E-A-T is crucial for AI trust contentSchema: 0.25, // 25% - Structured data is key for AI understanding visibility: 0.20, // 20% - How AI surfaces your content localEntity: 0.15, // 15% - Entity recognition important but not critical serviceArea: 0.10 // 10% - Less critical for AI search }; // Sort pillars by weight (highest to lowest) for consistent ordering const getOrderedPillars = (scoresObj) => { // Filter out non-pillar keys (like authorityComponents) const validPillars = ['localEntity', 'serviceArea', 'authority', 'visibility', 'contentSchema']; return Object.entries(scoresObj) .filter(([key]) => validPillars.includes(key)) .map(([key, scoreValue]) => { // Handle new Authority structure (object with score) or legacy (number) let score = scoreValue; if (key === 'authority' && typeof scoreValue === 'object' && scoreValue !== null) { score = scoreValue.score || 0; } return [key, score]; }) .sort((a, b) => { return (pillarWeights[b[0]] || 0) - (pillarWeights[a[0]] || 0); }); }; // Use current audit data (passed as parameter) for health dashboard // This ensures we use the most recent audit run data, not stale localStorage // Handle both status/data structure and direct data structure const hasLocalSignals = currentLocalSignals && ( (currentLocalSignals.status === 'ok' && currentLocalSignals.data) || (currentLocalSignals.data && (currentLocalSignals.data.napConsistencyScore !== undefined || currentLocalSignals.data.locationsScore !== undefined)) ); const localSignalsData = hasLocalSignals ? (currentLocalSignals.data || currentLocalSignals) : null; // DEBUG: Log local signals status for health dashboard debugLog(`[Health Dashboard] Local signals: hasLocalSignals=${hasLocalSignals}, currentLocalSignals=${JSON.stringify(currentLocalSignals ? {status: currentLocalSignals.status, hasData: !!currentLocalSignals.data} : null)}`, 'info'); // DEBUG: Verify brandOverlay is populated console.log('DEBUG brandOverlay', scores.brandOverlay); debugLog(`DEBUG brandOverlay: ${JSON.stringify(scores.brandOverlay)}`, 'info'); // Calculate GAIO Health Score const aiGeoHealth = calculateAiGeoScore(scores, schemaAudit, snippetReadiness); debugLog(`GAIO Health: Score=${aiGeoHealth.aiGeoScore}, Status=${aiGeoHealth.aiGeoStatus}, Likelihood=${aiGeoHealth.aiSummaryLikelihood}`, 'info'); // Extract component scores for health dashboard const authorityScore = (typeof scores.authority === 'object' && scores.authority !== null) ? scores.authority.score : scores.authority || 0; const contentScore = scores.contentSchema || 0; // Extract coverage and diversity from schema audit (same logic as calculateAiGeoScore) let coverageScore = scores.coverage || 0; let diversityScore = scores.diversity || 0; if (schemaAudit && schemaAudit.status === 'ok' && schemaAudit.data) { const schemaData = schemaAudit.data; coverageScore = schemaData.coverage || 0; const allTypes = new Set(); if (schemaData.allDetectedTypes && Array.isArray(schemaData.allDetectedTypes)) { schemaData.allDetectedTypes.forEach(type => { if (type) allTypes.add(type); }); } else if (schemaData.schemaTypes && Array.isArray(schemaData.schemaTypes)) { schemaData.schemaTypes.forEach(item => { if (item.type) allTypes.add(item.type); }); } const uniqueTypesCount = allTypes.size; diversityScore = Math.min((uniqueTypesCount / 15) * 100, 100); } const locationsScore = ((scores.localEntity || 0) + (scores.serviceArea || 0)) / 2; // Create GAIO Health Dashboard (insert before pillar cards) const healthDashboard = document.createElement('div'); healthDashboard.id = 'ai-geo-health-dashboard'; healthDashboard.style.marginBottom = '2rem'; // Get pillar hints/comments for the comparison table const getPillarHint = (pillarKey, score, scoresObj, schemaAuditData, localSignalsData) => { switch(pillarKey) { case 'authority': const authComponents = scoresObj?.authorityComponents; if (authComponents) { if (authComponents.behaviour < 30 && authComponents.backlinks >= 50 && authComponents.reviews >= 50) { return 'Backlinks and reviews strong; CTR weak'; } else if (authComponents.behaviour >= 50 && authComponents.ranking < 30) { return 'CTR strong; ranking needs improvement'; } else if (authComponents.behaviour < 30) { return 'Behaviour is weak; backlinks/reviews strong'; } } return 'E-A-T signals balanced'; case 'content': if (score >= 90) return 'Fully structured, rich schema'; if (score >= 70) return 'Good schema coverage'; return 'Schema needs improvement'; case 'coverage': if (score >= 90) return 'All priority pages ingested'; if (score >= 70) return 'Most pages covered'; return 'Coverage gaps present'; case 'diversity': if (score >= 80) return 'Good mix of topics and formats'; if (score >= 60) return 'Moderate diversity'; return 'Limited schema diversity'; case 'locations': if (score >= 85) return 'GBP + core locations covered well'; if (score >= 70) return 'Local entity and service areas solid'; return 'Location signals need strengthening'; default: return ''; } }; // Phase 2: Get brand priority const getBrandPriority = (summary) => { const b = summary.brandOverlay; if (!b) return null; if (b.score >= 70) return null; // only suggest actions if score is below "Strong" const share = b.brandQueryShare || 0; const ctr = b.brandCtr || 0; const entity = b.entityScore ?? 0; const review = b.reviewScore ?? 0; // Decide the most important brand-related action if (share < 0.10) { return { pillar: 'Brand & Entity', severity: 'medium', message: 'Increase the share of branded searches (e.g. "Alan Ranger Photography") by using your full brand name consistently in campaigns, key landing pages and off-site mentions.', link: '#pillarCards', score: b.score }; } if (ctr < 0.25) { return { pillar: 'Brand & Entity', severity: 'medium', message: 'Improve titles and meta descriptions on core brand pages (home, about, tuition, workshops) to raise CTR on branded queries above 25%.', link: '#pillarCards', score: b.score }; } if (entity < 70) { return { pillar: 'Brand & Entity', severity: 'medium', message: 'Strengthen entity signals by maintaining NAP consistency, adding more detail to GBP and About/Press pages, and earning mentions on relevant third-party sites.', link: '#pillarCards', score: b.score }; } if (review < 70) { return { pillar: 'Brand & Entity', severity: 'medium', message: 'Boost review signals with a steady flow of new Google reviews and clear calls-to-review after workshops and lessons.', link: '#pillarCards', score: b.score }; } // Fallback for mid-range scores return { pillar: 'Brand & Entity', severity: 'low', message: 'Continue building branded visibility by consolidating strong informational content into clear "hub" pages that use your brand name and service area.', link: '#pillarCards', score: b.score }; }; // Generate priorities list from pillar diagnostics // Helper to generate money pages priority (Phase 2) function getMoneyPagesPriority(moneyPagesMetrics) { if (!moneyPagesMetrics || !moneyPagesMetrics.rows || !moneyPagesMetrics.rows.length) { debugLog('getMoneyPagesPriority: No rows data', 'warn'); return null; } const { rows, summaryByCategory, overview } = moneyPagesMetrics; const moneyImpressions = overview.moneyImpressions || 0; if (moneyImpressions === 0) { debugLog('getMoneyPagesPriority: No money impressions', 'warn'); return null; } const high = summaryByCategory?.HIGH_OPPORTUNITY || { count: 0, impressions: 0 }; const vis = summaryByCategory?.VISIBILITY_FIX || { count: 0, impressions: 0 }; const highCount = high.count || 0; const highImps = high.impressions || 0; const visCount = vis.count || 0; const visImps = vis.impressions || 0; debugLog(`getMoneyPagesPriority: high=${highCount} pages/${highImps} imps, vis=${visCount} pages/${visImps} imps, total=${moneyImpressions}`, 'info'); const totalFocusImps = highImps + visImps; if (totalFocusImps === 0) { debugLog('getMoneyPagesPriority: No focus impressions (high+vis)', 'warn'); return null; } const sharePct = (totalFocusImps / moneyImpressions) * 100; let severity = 'medium'; if (sharePct >= 60) severity = 'high'; else if (sharePct <= 25) severity = 'low'; const topExamples = rows .filter((r) => r.category === 'HIGH_OPPORTUNITY' || r.category === 'VISIBILITY_FIX') .slice(0, 3) .map((r) => { try { const urlObj = new URL(r.url); return urlObj.pathname.split('/').filter(p => p).pop() || r.url; } catch (e) { return r.url; } }); const description = `Money pages: ${highCount} high-opportunity and ${visCount} visibility-fix URLs ` + `account for ${sharePct.toFixed(1)}% of money-page impressions. ` + `Prioritise improving titles/meta, "best" framing, and FAQs on these pages.`; const detail = topExamples.length ? `Examples: ${topExamples.join(', ')}` : ''; const priority = { pillar: 'Money Pages', severity, // 'high' | 'medium' | 'low' message: description, detail: detail, link: '#money-pages-section' }; debugLog(`✓ Money pages priority created: ${JSON.stringify(priority)}`, 'success'); return priority; } const getPriorities = (scoresObj, schemaAuditData, localSignalsData, authorityComponents) => { const priorities = []; // Authority priorities if (authorityComponents) { if (authorityComponents.behaviour < 30) { priorities.push({ pillar: 'Authority', severity: 'high', message: 'Improve Top-10 CTR on money pages (Authority → Behaviour table, status = Poor)', link: '#authority-top-pages-section' }); } if (authorityComponents.ranking < 30) { priorities.push({ pillar: 'Authority', severity: 'medium', message: 'Improve average position and top-10 impression share', link: '#authority-top-pages-section' }); } } // Content/Schema priorities if (schemaAuditData && schemaAuditData.status === 'ok' && schemaAuditData.data) { const schemaData = schemaAuditData.data; if (schemaData.coverage < 100) { // Use missingSchemaCount if available, otherwise calculate from coverage const missingCount = schemaData.missingSchemaCount || (schemaData.totalPages ? Math.round((100 - schemaData.coverage) / 100 * schemaData.totalPages) : 0); priorities.push({ pillar: 'Content', severity: 'medium', message: `Add schema to ${missingCount > 0 ? missingCount : 'remaining'} pages (Content/Schema → coverage ${schemaData.coverage != null ? schemaData.coverage.toFixed(0) : 'N/A'}%)`, link: '#pillarCards' }); } const foundationTypes = ['Organization', 'Person', 'WebSite', 'BreadcrumbList']; // PRIORITY: Use foundation object first (most reliable source) - same logic as Content/Schema card let foundationPresent = 0; if (schemaData.foundation && typeof schemaData.foundation === 'object') { // Use foundation object directly - count how many are true (same as Content/Schema card) foundationPresent = foundationTypes.filter(type => schemaData.foundation[type] === true).length; } else { // Fallback: check allDetectedTypes or schemaTypes const allTypes = new Set(); if (schemaData.allDetectedTypes && Array.isArray(schemaData.allDetectedTypes)) { schemaData.allDetectedTypes.forEach(type => { if (type) allTypes.add(type); }); } else if (schemaData.schemaTypes && Array.isArray(schemaData.schemaTypes)) { schemaData.schemaTypes.forEach(item => { if (item && typeof item === 'object' && item.type) { allTypes.add(item.type); } else if (typeof item === 'string') { allTypes.add(item); } }); } foundationPresent = foundationTypes.filter(type => allTypes.has(type)).length; } if (foundationPresent < 4) { // Determine missing types based on which data structure was used const missingTypes = schemaData.foundation && typeof schemaData.foundation === 'object' ? foundationTypes.filter(t => schemaData.foundation[t] !== true) : foundationTypes.filter(t => { // Fallback: check allTypes set if foundation object not available const allTypes = new Set(); if (schemaData.allDetectedTypes && Array.isArray(schemaData.allDetectedTypes)) { schemaData.allDetectedTypes.forEach(type => { if (type) allTypes.add(type); }); } else if (schemaData.schemaTypes && Array.isArray(schemaData.schemaTypes)) { schemaData.schemaTypes.forEach(item => { if (item && typeof item === 'object' && item.type) allTypes.add(item.type); else if (typeof item === 'string') allTypes.add(item); }); } return !allTypes.has(t); }); priorities.push({ pillar: 'Content', severity: 'high', message: `Add missing foundation schemas: ${missingTypes.join(', ')}`, link: '#pillarCards' }); } } // Locations priorities if (localSignalsData) { if (localSignalsData.napConsistencyScore < 100) { priorities.push({ pillar: 'Locations', severity: 'medium', message: `Improve NAP consistency (currently ${localSignalsData.napConsistencyScore}%)`, link: '#pillarCards' }); } const serviceAreasCount = localSignalsData.serviceAreas?.length || 0; if (serviceAreasCount < 5) { priorities.push({ pillar: 'Locations', severity: 'low', message: `Add more service areas to Google Business Profile (currently ${serviceAreasCount}, target: 5+)`, link: '#pillarCards' }); } } // Phase 2: Add brand priority if applicable const brandPriority = getBrandPriority ? getBrandPriority({ brandOverlay: scoresObj.brandOverlay }) : null; if (brandPriority) { priorities.push(brandPriority); } // Phase 2: Add money pages priority if applicable debugLog(`Checking for money pages priority - moneyPagesMetrics exists: ${!!scoresObj.moneyPagesMetrics}`, 'info'); if (scoresObj.moneyPagesMetrics) { debugLog(`Money pages metrics: rows=${scoresObj.moneyPagesMetrics.rows?.length || 0}, summaryByCategory=${!!scoresObj.moneyPagesMetrics.summaryByCategory}`, 'info'); } const moneyPriority = getMoneyPagesPriority(scoresObj.moneyPagesMetrics); if (moneyPriority) { debugLog(`✓ Adding money pages priority: ${moneyPriority.message}`, 'success'); priorities.push(moneyPriority); } else { debugLog('⚠ No money pages priority generated', 'warn'); } // Sort by severity (high > medium > low) and limit to top items const severityOrder = { high: 3, medium: 2, low: 1 }; const sorted = priorities.sort((a, b) => severityOrder[b.severity] - severityOrder[a.severity]); // Allow up to 4 items when brand or money pages priorities are present, or 5 otherwise const hasOverlayPriorities = brandPriority || moneyPriority; const maxItems = hasOverlayPriorities ? 4 : 5; return sorted.slice(0, maxItems); }; const priorities = getPriorities(scores, schemaAudit, localSignalsData, scores.authorityComponents); healthDashboard.innerHTML = `

Site AI Health

${(() => { const score = aiGeoHealth.aiGeoScore; const center = 260; const radius = 195; const strokeWidth = 26; const normalizedRadius = radius - strokeWidth / 2; // Determine colors based on status const color = aiGeoHealth.aiGeoStatus === 'green' ? '#10b981' : aiGeoHealth.aiGeoStatus === 'amber' ? '#f59e0b' : '#ef4444'; const bgColor = aiGeoHealth.aiGeoStatus === 'green' ? '#d1fae5' : aiGeoHealth.aiGeoStatus === 'amber' ? '#fef3c7' : '#fee2e2'; // Angle calculations: 0% at 12:05 (-85°), 50% at 6pm (90°), 100% at 12pm (-90°) const startAngle = -85; // 12:05 position const endAngle = -90; // 12pm position const angleRange = 360 - 5; // Full circle minus 5° gap // Helper function to create arc path const createArc = (start, end, r) => { const startRad = (start * Math.PI) / 180; const endRad = (end * Math.PI) / 180; const largeArc = Math.abs(end - start) > 180 ? 1 : 0; const x1 = center + r * Math.cos(startRad); const y1 = center + r * Math.sin(startRad); const x2 = center + r * Math.cos(endRad); const y2 = center + r * Math.sin(endRad); return `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`; }; // Calculate segment angles const angle0 = startAngle; const angle50 = startAngle + angleRange * 0.5; const angle70 = startAngle + angleRange * 0.7; const angle100 = startAngle + angleRange; // Calculate current score angle const scoreAngle = startAngle + (score / 100) * angleRange; const scoreRad = (scoreAngle * Math.PI) / 180; return ` ${(() => { // Get all three scores const aiGeoScoreValue = score; // Already calculated const brandScore = scores?.brandOverlay?.score || 0; const aiSummaryScore = aiGeoHealth?.aiSummary?.score || 0; // Calculate angles for each score const aiGeoAngle = startAngle + (aiGeoScoreValue / 100) * angleRange; const brandAngle = startAngle + (brandScore / 100) * angleRange; const aiSummaryAngle = startAngle + (aiSummaryScore / 100) * angleRange; const aiGeoRad = (aiGeoAngle * Math.PI) / 180; const brandRad = (brandAngle * Math.PI) / 180; const aiSummaryRad = (aiSummaryAngle * Math.PI) / 180; // GAIO Score tick mark (solid, thick) - scaled 30% larger const aiGeoTickLength = 31.2; const aiGeoTickWidth = 5.2; const aiGeoTickX1 = center + (normalizedRadius - aiGeoTickLength) * Math.cos(aiGeoRad); const aiGeoTickY1 = center + (normalizedRadius - aiGeoTickLength) * Math.sin(aiGeoRad); const aiGeoTickX2 = center + (normalizedRadius + aiGeoTickLength) * Math.cos(aiGeoRad); const aiGeoTickY2 = center + (normalizedRadius + aiGeoTickLength) * Math.sin(aiGeoRad); // AI Summary Likelihood tick mark (solid, medium) - scaled 30% larger const aiSummaryTickLength = 26; const aiSummaryTickWidth = 3.9; const aiSummaryTickX1 = center + (normalizedRadius - aiSummaryTickLength) * Math.cos(aiSummaryRad); const aiSummaryTickY1 = center + (normalizedRadius - aiSummaryTickLength) * Math.sin(aiSummaryRad); const aiSummaryTickX2 = center + (normalizedRadius + aiSummaryTickLength) * Math.cos(aiSummaryRad); const aiSummaryTickY2 = center + (normalizedRadius + aiSummaryTickLength) * Math.sin(aiSummaryRad); // Brand & Entity tick mark (dashed, medium) - scaled 30% larger const brandTickLength = 23.4; const brandTickWidth = 3.9; const brandTickX1 = center + (normalizedRadius - brandTickLength) * Math.cos(brandRad); const brandTickY1 = center + (normalizedRadius - brandTickLength) * Math.sin(brandRad); const brandTickX2 = center + (normalizedRadius + brandTickLength) * Math.cos(brandRad); const brandTickY2 = center + (normalizedRadius + brandTickLength) * Math.sin(brandRad); // Colors based on scores (consistent with badge logic) // AI Summary uses same RAG bands as GAIO: ≥70 green, ≥50 amber, <50 red const aiSummaryColor = aiSummaryScore >= 70 ? '#10b981' : aiSummaryScore >= 50 ? '#f59e0b' : '#ef4444'; const brandColor = brandScore >= 70 ? '#10b981' : brandScore >= 40 ? '#f59e0b' : '#ef4444'; return ` ${aiSummaryScore > 0 ? ` ` : ''} ${brandScore > 0 ? ` ` : ''} `; })()} ${(() => { // Get all three scores const aiGeoScoreValue = score; const brandScore = scores?.brandOverlay?.score || 0; const aiSummaryScore = aiGeoHealth?.aiSummary?.score || 0; // Calculate angles const aiGeoAngle = startAngle + (aiGeoScoreValue / 100) * angleRange; const brandAngle = startAngle + (brandScore / 100) * angleRange; const aiSummaryAngle = startAngle + (aiSummaryScore / 100) * angleRange; // Colors - AI Summary uses same RAG bands as GAIO: ≥70 green, ≥50 amber, <50 red const aiSummaryColor = aiSummaryScore >= 70 ? '#10b981' : aiSummaryScore >= 50 ? '#f59e0b' : '#ef4444'; const brandColor = brandScore >= 70 ? '#10b981' : brandScore >= 40 ? '#f59e0b' : '#ef4444'; const labels = [ { value: 100, angle: angle100, color: '#10b981' }, // Scale marker { value: aiGeoScoreValue, angle: aiGeoAngle, color: color, label: 'GAIO' }, // Main score ...(aiSummaryScore > 0 ? [{ value: aiSummaryScore, angle: aiSummaryAngle, color: aiSummaryColor, label: 'AI Summary' }] : []), ...(brandScore > 0 ? [{ value: brandScore, angle: brandAngle, color: brandColor, label: 'Brand' }] : []) ]; return labels.map(m => { const rad = (m.angle * Math.PI) / 180; const labelOffset = 65; const labelX = center + (normalizedRadius + labelOffset) * Math.cos(rad); const labelY = center + (normalizedRadius + labelOffset) * Math.sin(rad); // For scores, show value with label below if (m.label) { return ` ${m.value} ${m.label} `; } else { // Scale markers (50, 100) return ` ${m.value} `; } }).join(''); })()}
${score}
GAIO Score
${aiGeoHealth.aiGeoStatus === 'green' ? '✓ Excellent' : aiGeoHealth.aiGeoStatus === 'amber' ? '⚠ Good' : '✗ Needs Work'}
`; })()}
GAIO Status:
${(() => { // Build GAIO score breakdown summary const authorityScore = (typeof scores.authority === 'object' && scores.authority !== null) ? scores.authority.score : scores.authority || 0; const contentScore = scores.contentSchema || 0; const visibilityScore = scores.visibility || 0; const localEntityScore = scores.localEntity || 0; const serviceAreaScore = scores.serviceArea || 0; const aiGeoBreakdown = `GAIO Score Breakdown: • Authority: ${Math.round(authorityScore)} (30% weight) • Content/Schema: ${Math.round(contentScore)} (25% weight) • Visibility: ${Math.round(visibilityScore)} (20% weight) • Local Entity: ${Math.round(localEntityScore)} (15% weight) • Service Area: ${Math.round(serviceAreaScore)} (10% weight) Final Score: ${aiGeoHealth.aiGeoScore}/100`; return `
${aiGeoHealth.aiGeoStatus === 'green' ? 'Green' : aiGeoHealth.aiGeoStatus === 'amber' ? 'Amber' : 'Red'} (${aiGeoHealth.aiGeoScore}/100) i
GAIO Score Breakdown:
• Authority: ${Math.round(authorityScore)} (30% weight)
• Content/Schema: ${Math.round(contentScore)} (25% weight)
• Visibility: ${Math.round(visibilityScore)} (20% weight)
• Local Entity: ${Math.round(localEntityScore)} (15% weight)
• Service Area: ${Math.round(serviceAreaScore)} (10% weight)
Final Score: ${aiGeoHealth.aiGeoScore}/100
`; })()}
AI summary likelihood:
${(() => { // Phase 2: Use aiSummary.label if available, otherwise fallback to aiSummaryLikelihood const aiSummary = aiGeoHealth.aiSummary || { label: aiGeoHealth.aiSummaryLikelihood || 'Low', score: 0 }; // Ensure label is capitalized correctly let label = aiSummary.label || 'Low'; if (typeof label === 'string') { label = label.charAt(0).toUpperCase() + label.slice(1).toLowerCase(); } const score = aiSummary.score || 0; // Use same RAG bands as GAIO Score: ≥70 green, ≥50 amber, <50 red const bgColor = score >= 70 ? '#d1fae5' : score >= 50 ? '#fef3c7' : '#fee2e2'; const textColor = score >= 70 ? '#065f46' : score >= 50 ? '#92400e' : '#991b1b'; // Build AI Summary breakdown // Handle both number format (legacy) and object format (if it exists) const snippetReadinessScore = typeof snippetReadiness === 'number' ? snippetReadiness : (snippetReadiness?.overallScore || 0); const visibilityScore = scores.visibility || 0; const brandScore = scores.brandOverlay?.score || 0; const aiSummaryBreakdown = `AI Summary Likelihood Breakdown: • Snippet Readiness: ${Math.round(snippetReadinessScore)}/100 (50% weight) • Visibility: ${Math.round(visibilityScore)}/100 (30% weight) • Brand & Entity: ${Math.round(brandScore)}/100 (20% weight) Final Score: ${score}/100 Based on snippet-friendly content (FAQ / HowTo / Article / Event blocks), how often your content appears in rich results, and the strength of your brand & entity signals (branded searches, reviews, knowledge panel).`; return `
${label} (${score}/100) i
AI Summary Likelihood Breakdown:
• Snippet Readiness: ${Math.round(snippetReadinessScore)}/100 (50% weight)
• Visibility: ${Math.round(visibilityScore)}/100 (30% weight)
• Brand & Entity: ${Math.round(brandScore)}/100 (20% weight)
Final Score: ${score}/100
`; })()}
Brand & entity:
${(() => { // Brand & Entity chip - show even if missing (with N/A) const brandOverlay = scores.brandOverlay; if (brandOverlay) { const score = brandOverlay.score || 0; const label = brandOverlay.label || 'Weak'; const bgColor = score >= 70 ? '#d1fae5' : score >= 40 ? '#fef3c7' : '#fee2e2'; const textColor = score >= 70 ? '#065f46' : score >= 40 ? '#92400e' : '#991b1b'; const brandQueryShare = brandOverlay.brandQueryShare != null ? (brandOverlay.brandQueryShare * 100).toFixed(1) : 'N/A'; const brandCtr = brandOverlay.brandCtr != null ? (brandOverlay.brandCtr * 100).toFixed(1) : 'N/A'; const brandAvgPos = brandOverlay.brandAvgPosition != null ? brandOverlay.brandAvgPosition.toFixed(1) : 'N/A'; const reviewScore = brandOverlay.reviewScore != null ? brandOverlay.reviewScore : 'N/A'; const entityScore = brandOverlay.entityScore != null ? brandOverlay.entityScore : 'N/A'; const brandTooltip = `Brand & Entity Overlay Breakdown: • Brand Query Share: ${brandQueryShare}% of impressions • Brand CTR: ${brandCtr}% • Avg Brand Position: ${brandAvgPos} • Review Score: ${reviewScore}/100 • Entity Score: ${entityScore}/100 Final Score: ${score}/100`; return `
${label} (${score}/100) i
Brand & Entity Overlay Breakdown:
• Brand Query Share: ${brandQueryShare}% of impressions
• Brand CTR: ${brandCtr}%
• Avg Brand Position: ${brandAvgPos}
• Review Score: ${reviewScore}/100
• Entity Score: ${entityScore}/100
Final Score: ${score}/100
`; } else { // Show N/A if brandOverlay is missing const brandTooltip = 'No brand overlay data available – run an audit with access to query data.'; return `
N/A i
No brand overlay data available – run an audit with access to query data.
`; } })()}

Based on Authority, Content & Schema, Coverage, Diversity, Locations and Brand & entity signals.

${priorities.length > 0 ? `

This Month's Priorities

    ${priorities.map((p, idx) => { // Phase 2: For brand priorities, use brandOverlay score for color let severityColor; if (p.pillar === 'Brand & Entity' && p.score !== undefined) { severityColor = p.score >= 70 ? '#10b981' : p.score >= 40 ? '#f59e0b' : '#ef4444'; } else { severityColor = p.severity === 'high' ? '#ef4444' : p.severity === 'medium' ? '#f59e0b' : '#10b981'; } return `
  • ${p.message}
  • `; }).join('')}
` : ''}
`; // Insert health dashboard before pillar cards const pillarCards = document.getElementById('pillarCards'); if (!pillarCards) { debugLog('✗ Pillar cards container not found', 'error'); console.error('Pillar cards container (id="pillarCards") not found in DOM'); return; } debugLog('✓ Pillar cards container found', 'success'); // Remove existing health dashboard if present const existingHealth = document.getElementById('ai-geo-health-dashboard'); if (existingHealth) { existingHealth.remove(); } // Insert health dashboard before pillar cards pillarCards.parentNode.insertBefore(healthDashboard, pillarCards); debugLog('✓ GAIO Health Dashboard created', 'success'); // Circular progress ring is SVG-based, no additional drawing needed // Clear pillar cards for fresh render pillarCards.innerHTML = ''; const orderedPillars = getOrderedPillars(scores); debugLog(`Creating ${orderedPillars.length} pillar cards from scores: ${JSON.stringify(scores)}`, 'info'); if (orderedPillars.length === 0) { debugLog('⚠ No pillars found in scores object!', 'warn'); console.warn('No pillars found in scores:', scores); } orderedPillars.forEach(([key, scoreValue]) => { // Handle new Authority structure (object with score and bySegment) or legacy (number) // Note: getOrderedPillars extracts the score, so we need to get bySegment from original scores object let score = scoreValue; let authorityBySegment = null; if (key === 'authority') { // Get the original Authority object from scores (not from getOrderedPillars result) const authorityObj = scores.authority; if (typeof authorityObj === 'object' && authorityObj !== null) { score = authorityObj.score || 0; authorityBySegment = authorityObj.bySegment || null; } } const rag = getRAGStatus(score); const card = document.createElement('div'); card.className = 'pillar-card'; // Get pillar description with data source info (dynamic based on whether we have real data) // Use currentLocalSignals (from displayDashboard parameter) for pillar cards, not just health dashboard // Check both localSignals and localSignalsSnapshot (different storage formats) const pillarLocalSignals = currentLocalSignals || (saved && (saved.localSignals || saved.localSignalsSnapshot)) || null; // Handle both status/data structure and direct data structure const pillarHasLocalSignals = pillarLocalSignals && ( (pillarLocalSignals.status === 'ok' && pillarLocalSignals.data) || (pillarLocalSignals.data && (pillarLocalSignals.data.napConsistencyScore !== undefined || pillarLocalSignals.data.locationsScore !== undefined)) ); const pillarLocalSignalsData = pillarHasLocalSignals ? (pillarLocalSignals.data || pillarLocalSignals) : null; // DEBUG: Log local signals status for pillar cards debugLog(`[Pillar Cards] Local signals in definitions: hasLocalSignals=${pillarHasLocalSignals}, currentLocalSignals=${JSON.stringify(pillarLocalSignals ? {status: pillarLocalSignals.status, hasData: !!pillarLocalSignals.data, hasLocations: !!pillarLocalSignals.data?.locations, locationsCount: pillarLocalSignals.data?.locations?.length || 0} : null)}`, 'info'); if (pillarLocalSignals && pillarLocalSignals.data) { debugLog(`[Pillar Cards] pillarLocalSignals.data keys: ${Object.keys(pillarLocalSignals.data).join(', ')}`, 'info'); debugLog(`[Pillar Cards] pillarLocalSignals.data.locations type: ${typeof pillarLocalSignals.data.locations}, isArray: ${Array.isArray(pillarLocalSignals.data.locations)}, length: ${pillarLocalSignals.data.locations?.length || 0}`, 'info'); } let localEntityDesc, serviceAreaDesc; if (pillarHasLocalSignals && pillarLocalSignalsData) { const napScore = pillarLocalSignalsData.napConsistencyScore !== null ? pillarLocalSignalsData.napConsistencyScore : 'N/A'; const serviceAreasCount = pillarLocalSignalsData.serviceAreas?.length || 0; const locationsCount = pillarLocalSignalsData.locations?.length || 0; localEntityDesc = `How clearly your business and you as a person are recognised as entities by AI search systems. Data Source: ✅ Live data from Google Business Profile API. Data Checked: NAP consistency (${napScore}%), knowledge panel (${pillarLocalSignalsData.knowledgePanelDetected ? 'detected' : 'not detected'}), locations (${locationsCount}).`; serviceAreaDesc = `How well AI understands where you operate and which regions you serve. Data Source: ✅ Live data from Google Business Profile API. Data Checked: Service areas (${serviceAreasCount}), NAP consistency (${napScore}%).`; } else { localEntityDesc = 'How clearly your business and you as a person are recognised as entities by AI search systems. Data Source: ⚠️ Currently derived from search performance (GSC position/CTR). Real local signals API integration pending.'; serviceAreaDesc = 'How well AI understands where you operate and which regions you serve. Data Source: ⚠️ Currently derived from Local Entity score. Real service area data from Google Business Profile pending.'; } const descriptions = { localEntity: localEntityDesc, serviceArea: serviceAreaDesc, authority: 'E-A-T (Experience, Expertise, Authoritativeness, Trustworthiness): Perceived expertise and trust in the topic space. Calculation: Behaviour (40%) + Ranking (20%) + Backlinks (20%) + Reviews (20%). Data Source: ✅ Live data from Google Search Console, Google Business Profile API, historic Trustpilot reviews snapshot, and backlink CSV upload.', visibility: 'How prominently your content appears in AI-powered search results and featured snippets. Data Source: ✅ Live data from Google Search Console (impressions, clicks, position, SERP features).', contentSchema: 'Quality and completeness of structured data markup across your domain. Data Source: ✅ Live data from schema audit. Calculation: Foundation schemas (30%) + Rich Results (35%) + Coverage (20%) + Diversity (15%).' }; // Build breakdown and details sections for all pillars (for consistency) let pillarBreakdown = ''; let pillarDetails = ''; let authorityBreakdown = ''; let authorityDetails = ''; let authorityModeToggle = ''; // Get saved audit data for breakdown calculations (reuse saved from function start to avoid redeclaration) // Use schemaAudit parameter if available, otherwise fall back to saved const schemaAuditData = schemaAudit || saved?.schemaAudit; const searchDataForBreakdown = data || saved?.searchData; // Note: hasLocalSignals and localSignalsData are already defined at function level (line 3688-3689), reuse them if (key === 'authority') { const savedAudit = loadAuditResultsSync(); // Try multiple sources for backlinkMetrics: savedAudit, then localStorage // Note: API call removed from here since forEach doesn't support async properly // Backlink metrics should be loaded during audit run and saved to savedAudit let backlinkMetrics = savedAudit?.backlinkMetrics || null; if (!backlinkMetrics) { try { const storedMetrics = localStorage.getItem('backlink_metrics'); if (storedMetrics) { backlinkMetrics = JSON.parse(storedMetrics); debugLog('✓ Backlink metrics loaded from localStorage for Authority card', 'info'); } } catch (e) { debugLog(`⚠ Error reading backlink metrics from localStorage: ${e.message}`, 'warn'); } } // Use currentLocalSignals from displayDashboard parameter, or fallback to savedAudit const localSignals = currentLocalSignals || savedAudit?.localSignals || null; // Always use correct Trustpilot snapshot (4.6, 610) - override any old cached values const siteReviews = getTrustpilotSnapshot(savedAudit?.siteReviews); const searchData = savedAudit?.searchData; // Authority mode state (stored per card instance) const modeId = `authority-mode-${Date.now()}`; let currentMode = 'all'; // Default mode // Get selected Authority scores based on mode const getAuthorityForMode = (mode) => { if (authorityBySegment && authorityBySegment[mode]) { return authorityBySegment[mode]; } // Fallback to all or legacy structure if (authorityBySegment && authorityBySegment.all) { return authorityBySegment.all; } // Legacy fallback const components = scores.authorityComponents || {}; return { total: score, behaviour: components.behaviour || 0, ranking: components.ranking || 0, backlinks: components.backlinks || 0, reviews: components.reviews || 0 }; }; // Get current Authority data let selectedAuthority = getAuthorityForMode(currentMode); // DEBUG: Log authority components and backlink metrics debugLog(`[Authority Card] Current authority components: ${JSON.stringify(selectedAuthority)}`, 'info'); debugLog(`[Authority Card] Scores authorityComponents: ${JSON.stringify(scores.authorityComponents)}`, 'info'); // Build mode toggle UI (only if bySegment is available) if (authorityBySegment) { authorityModeToggle = `
Mode:
`; } // Build breakdown display with color-coded scores (will be updated by mode toggle) const updateAuthorityDisplay = () => { const mode = card._authorityMode || currentMode; selectedAuthority = getAuthorityForMode(mode); const breakdownDiv = document.getElementById(`${modeId}-breakdown`); const scoreDiv = document.getElementById(`${modeId}-score`); if (breakdownDiv) { breakdownDiv.innerHTML = `
${formatComponentScore('Behaviour', selectedAuthority.behaviour)} ${formatComponentScore('Ranking', selectedAuthority.ranking)} ${formatComponentScore('Backlinks', selectedAuthority.backlinks)} ${formatComponentScore('Reviews', selectedAuthority.reviews)}
`; } if (scoreDiv) { scoreDiv.textContent = Math.round(selectedAuthority.total); const newRag = getRAGStatus(selectedAuthority.total); scoreDiv.className = `pillar-score rag-${newRag.status}`; } }; // Initial breakdown (with consistent spacing for alignment) authorityBreakdown = `
${formatComponentScore('Behaviour', selectedAuthority.behaviour)} ${formatComponentScore('Ranking', selectedAuthority.ranking)} ${formatComponentScore('Backlinks', selectedAuthority.backlinks)} ${formatComponentScore('Reviews', selectedAuthority.reviews)}
`; // Store update function for mode toggle handlers card._updateAuthorityDisplay = updateAuthorityDisplay; card._authorityMode = currentMode; card._authorityBySegment = authorityBySegment; card._modeId = modeId; // Build details panel (hidden by default, toggled by button) const detailsId = `authority-details-${Date.now()}`; const buttonId = `authority-details-btn-${Date.now()}`; // Get GSC metrics for details (segment-aware) // Store queryPages and searchData on card for access in update functions card._queryPages = searchData?.queryPages || []; card._topQueries = searchData?.topQueries || []; const queryPages = card._queryPages; const topQueries = card._topQueries; // Function to calculate GSC metrics for a specific segment const getGSCMetricsForSegment = (mode, queryPagesData, topQueriesData) => { let dataToUse = []; // Use the passed data (from card storage) to ensure we have latest const qp = queryPagesData || queryPages; const tq = topQueriesData || topQueries; if (qp && qp.length > 0) { // Use queryPages with segmentation let filtered = qp; if (mode === 'nonEducation') { filtered = qp.filter(row => { const segment = classifyPageSegment(row.page || row.url || '/'); return segment !== PageSegment.EDUCATION; }); } else if (mode === 'money') { filtered = qp.filter(row => { const segment = classifyPageSegment(row.page || row.url || '/'); return segment === PageSegment.MONEY; }); } debugLog(`📊 GSC Metrics for ${mode}: Filtered ${filtered.length} rows from ${qp.length} total queryPages`, 'info'); // Convert to query format for calculation dataToUse = filtered.map(row => ({ query: row.query || '', clicks: row.clicks || 0, impressions: row.impressions || 0, ctr: (row.ctr || 0) / 100, position: row.position || 0 })); } else { // Fallback to topQueries (all pages) dataToUse = tq.map(q => ({ query: q.query || '', clicks: q.clicks || 0, impressions: q.impressions || 0, ctr: (q.ctr || 0) / 100, position: q.position || 0 })); } const rankingQueries = dataToUse.filter(q => q.position > 0 && q.position <= 20 && q.impressions > 0); const top10Queries = rankingQueries.filter(q => q.position <= 10); let siteCtr = 0; let top10Ctr = 0; let avgPosition = 0; let top10ImpressionShare = 0; if (rankingQueries.length > 0) { const totalClicks = rankingQueries.reduce((s, q) => s + (q.clicks || 0), 0); const totalImpr = rankingQueries.reduce((s, q) => s + (q.impressions || 0), 0); siteCtr = totalImpr > 0 ? (totalClicks / totalImpr) * 100 : 0; const top10Clicks = top10Queries.reduce((s, q) => s + (q.clicks || 0), 0); const top10Impr = top10Queries.reduce((s, q) => s + (q.impressions || 0), 0); top10Ctr = top10Impr > 0 ? (top10Clicks / top10Impr) * 100 : 0; avgPosition = totalImpr > 0 ? rankingQueries.reduce((s, q) => s + (q.position || 0) * (q.impressions || 0), 0) / totalImpr : 0; top10ImpressionShare = totalImpr > 0 ? (top10Impr / totalImpr) * 100 : 0; } return { siteCtr, top10Ctr, avgPosition, top10ImpressionShare }; }; // Get initial metrics (all pages) let gscMetrics = getGSCMetricsForSegment(currentMode, queryPages, topQueries); let siteCtr = gscMetrics.siteCtr; let top10Ctr = gscMetrics.top10Ctr; let avgPosition = gscMetrics.avgPosition; let top10ImpressionShare = gscMetrics.top10ImpressionShare; // Function to update GSC metrics display const updateGSCMetrics = (mode) => { // Get fresh data from card storage const qp = card._queryPages || []; const tq = card._topQueries || []; const metrics = getGSCMetricsForSegment(mode, qp, tq); const metricsDiv = document.getElementById(`${modeId}-gsc-metrics`); if (metricsDiv) { const siteCtrStr = metrics.siteCtr != null ? metrics.siteCtr.toFixed(2) : 'N/A'; const avgPosStr = metrics.avgPosition != null ? metrics.avgPosition.toFixed(1) : 'N/A'; debugLog(`📊 Updating GSC metrics for mode ${mode}: CTR=${siteCtrStr}%, Position=${avgPosStr}`, 'info'); const top10CtrStr = metrics.top10Ctr != null ? metrics.top10Ctr.toFixed(2) : 'N/A'; const top10ShareStr = metrics.top10ImpressionShare != null ? metrics.top10ImpressionShare.toFixed(1) : 'N/A'; metricsDiv.innerHTML = `
Behaviour & Ranking:
Segment: ${mode === 'all' ? 'All pages' : mode === 'nonEducation' ? 'Exclude education (blogs / free course)' : 'Money pages only'}
Site CTR (ranking queries): ${siteCtrStr}%
Top-10 CTR: ${top10CtrStr}%
Avg position (ranking): ${avgPosStr}
Top-10 impression share: ${top10ShareStr}%
`; } else { debugLog(`⚠ GSC metrics div not found: ${modeId}-gsc-metrics`, 'warn'); } }; // Store update function and data card._updateGSCMetrics = updateGSCMetrics; card._getGSCMetricsForSegment = getGSCMetricsForSegment; // Get review data - check multiple possible data structures let gbpRating = null; let gbpReviewCount = null; if (localSignals) { // Handle both direct data structure and wrapped response structure const signalsStatus = localSignals.status; const signalsData = localSignals.data || (localSignals.locations !== undefined ? localSignals : null); debugLog(`[Authority Card] Local signals status: ${signalsStatus}, has data: ${!!signalsData}, structure: ${JSON.stringify(Object.keys(localSignals))}`, 'info'); if (signalsStatus === 'ok' && signalsData) { // Enhanced logging for GBP data debugLog(`[Authority Card] signalsData keys: ${Object.keys(signalsData).join(', ')}`, 'info'); debugLog(`[Authority Card] signalsData.gbpRating raw: ${JSON.stringify(signalsData.gbpRating)} (type: ${typeof signalsData.gbpRating})`, 'info'); debugLog(`[Authority Card] signalsData.gbpReviewCount raw: ${JSON.stringify(signalsData.gbpReviewCount)} (type: ${typeof signalsData.gbpReviewCount})`, 'info'); gbpRating = signalsData.gbpRating !== null && signalsData.gbpRating !== undefined ? signalsData.gbpRating : null; gbpReviewCount = signalsData.gbpReviewCount !== null && signalsData.gbpReviewCount !== undefined ? signalsData.gbpReviewCount : null; debugLog(`[Authority Card] GBP data extracted: rating=${gbpRating}, count=${gbpReviewCount}`, gbpRating !== null ? 'info' : 'warn'); } else { debugLog(`[Authority Card] Local signals not OK or missing data. Status: ${signalsStatus}, data keys: ${signalsData ? Object.keys(signalsData).join(', ') : 'no data'}, full object keys: ${Object.keys(localSignals).join(', ')}`, 'warn'); } } else { debugLog(`[Authority Card] No localSignals available`, 'warn'); } const siteRating = siteReviews?.siteRating !== null ? siteReviews?.siteRating : null; const siteReviewCount = siteReviews?.siteReviewCount !== null ? siteReviews?.siteReviewCount : null; authorityDetails = `
`; // Store topPages data on card for access in update functions card._topPages = { all: authorityBySegment?.all?.topPages || [], nonEducation: authorityBySegment?.nonEducation?.topPages || [], money: authorityBySegment?.money?.topPages || [] }; // Function to render top pages table function renderTopPagesTable(mode, authorityBySegment) { const topPages = mode === 'all' ? (authorityBySegment?.all?.topPages || []) : mode === 'nonEducation' ? (authorityBySegment?.nonEducation?.topPages || []) : (authorityBySegment?.money?.topPages || []); if (!topPages || topPages.length === 0) { return '
No page data available for this segment.
'; } const segmentLabel = mode === 'all' ? 'All pages' : mode === 'nonEducation' ? 'Exclude education (blogs / free course)' : 'Money pages only'; const copyButtonId = `${modeId}-copy-urls`; let tableHtml = `

Top pages in this segment (by impressions)

Segment: ${segmentLabel}

`; topPages.forEach((page, idx) => { const ctrStr = page.ctr != null ? page.ctr.toFixed(1) : 'N/A'; const posStr = page.avgPosition != null ? page.avgPosition.toFixed(1) : 'N/A'; const impressionsStr = page.impressions != null ? page.impressions.toLocaleString() : 'N/A'; const clicksStr = page.clicks != null ? page.clicks.toLocaleString() : 'N/A'; tableHtml += ` `; }); tableHtml += `
# URL CTR Impr. Clicks Avg pos.
${idx + 1} ${page.url || 'N/A'} ${ctrStr}% ${impressionsStr} ${clicksStr} ${posStr}
`; // Store copy button ID and current mode for later event handler attachment card._copyButtonId = copyButtonId; card._topPagesForCopy = topPages; card._currentTopPagesMode = mode; return tableHtml; } // Function to update top pages table when mode changes const updateTopPagesTable = (mode) => { const topPagesDiv = document.getElementById(`${modeId}-top-pages`); if (topPagesDiv && authorityBySegment) { topPagesDiv.innerHTML = renderTopPagesTable(mode, authorityBySegment); // Re-attach copy button handler setTimeout(() => { const copyBtn = document.getElementById(card._copyButtonId); if (copyBtn) { // Remove existing listener if any const newCopyBtn = copyBtn.cloneNode(true); copyBtn.parentNode.replaceChild(newCopyBtn, copyBtn); newCopyBtn.addEventListener('click', async () => { const currentTopPages = mode === 'all' ? (authorityBySegment?.all?.topPages || []) : mode === 'nonEducation' ? (authorityBySegment?.nonEducation?.topPages || []) : (authorityBySegment?.money?.topPages || []); const text = currentTopPages.map(p => p.url).join('\n'); try { await navigator.clipboard.writeText(text); newCopyBtn.textContent = 'Copied!'; newCopyBtn.style.color = '#10b981'; setTimeout(() => { newCopyBtn.textContent = 'Copy URLs'; newCopyBtn.style.color = '#666'; }, 2000); } catch (err) { console.error('Failed to copy URLs:', err); newCopyBtn.textContent = 'Copy failed'; setTimeout(() => { newCopyBtn.textContent = 'Copy URLs'; }, 2000); } }); } }, 0); } }; // Store update function card._updateTopPagesTable = updateTopPagesTable; // Add click handlers for details toggle and mode toggle setTimeout(() => { const btn = document.getElementById(buttonId); const details = document.getElementById(detailsId); if (btn && details) { btn.addEventListener('click', () => { const isVisible = details.style.display !== 'none'; details.style.display = isVisible ? 'none' : 'block'; btn.textContent = isVisible ? 'Show details' : 'Hide details'; }); } // Add initial copy button handler const copyBtn = document.getElementById(card._copyButtonId); if (copyBtn) { copyBtn.addEventListener('click', async () => { const currentTopPages = currentMode === 'all' ? (authorityBySegment?.all?.topPages || []) : currentMode === 'nonEducation' ? (authorityBySegment?.nonEducation?.topPages || []) : (authorityBySegment?.money?.topPages || []); const text = currentTopPages.map(p => p.url).join('\n'); try { await navigator.clipboard.writeText(text); copyBtn.textContent = 'Copied!'; copyBtn.style.color = '#10b981'; setTimeout(() => { copyBtn.textContent = 'Copy URLs'; copyBtn.style.color = '#666'; }, 2000); } catch (err) { console.error('Failed to copy URLs:', err); copyBtn.textContent = 'Copy failed'; setTimeout(() => { copyBtn.textContent = 'Copy URLs'; }, 2000); } }); } // Add mode toggle handlers if (authorityBySegment) { ['all', 'nonEducation', 'money'].forEach(mode => { const modeBtn = document.getElementById(`${modeId}-${mode}`); if (modeBtn) { modeBtn.addEventListener('click', () => { // Update current mode currentMode = mode; card._authorityMode = mode; // Update button styles ['all', 'nonEducation', 'money'].forEach(m => { const btn = document.getElementById(`${modeId}-${m}`); if (btn) { if (m === mode) { btn.style.background = '#10b981'; btn.style.color = 'white'; } else { btn.style.background = 'white'; btn.style.color = '#666'; } } }); // Update Authority score and breakdown if (card._updateAuthorityDisplay) { card._updateAuthorityDisplay(); } // Update GSC metrics if (card._updateGSCMetrics) { card._updateGSCMetrics(mode); } // Update top pages section (full width, below pillars) window.currentAuthorityMode = mode; if (window.updateTopPagesSection) { window.updateTopPagesSection(mode); } debugLog(`📊 Authority mode changed to: ${mode === 'all' ? 'All pages' : mode === 'nonEducation' ? 'Exclude education' : 'Money pages only'}`, 'info'); }); } }); } }, 0); } // Build breakdown and details for other pillars (Content/Schema, Visibility, Local Entity, Service Area) if (key !== 'authority') { const breakdownId = `${key}-breakdown-${Date.now()}`; const detailsId = `${key}-details-${Date.now()}`; const buttonId = `${key}-details-btn-${Date.now()}`; if (key === 'contentSchema' && schemaAuditData && schemaAuditData.status === 'ok' && schemaAuditData.data) { const schemaData = schemaAuditData.data; const allTypes = new Set(); // PRIORITY: Use foundation object first (most reliable source) if (schemaData.foundation && typeof schemaData.foundation === 'object') { Object.keys(schemaData.foundation).forEach(type => { if (schemaData.foundation[type] === true) { allTypes.add(type); } }); } // Also add types from allDetectedTypes if available (for complete type list) if (schemaData.allDetectedTypes && Array.isArray(schemaData.allDetectedTypes)) { schemaData.allDetectedTypes.forEach(type => { if (type) allTypes.add(type); }); } // Also add from richEligible for complete type list if (schemaData.richEligible && typeof schemaData.richEligible === 'object') { Object.keys(schemaData.richEligible).forEach(type => { if (schemaData.richEligible[type] === true) { allTypes.add(type); } }); } // Fallback: collect from schemaTypes array (but filter out page objects) if (schemaData.schemaTypes && Array.isArray(schemaData.schemaTypes)) { schemaData.schemaTypes.forEach(item => { // Skip page objects (have 'url' property) - these are NOT schema types if (item && typeof item === 'object' && item.url) { return; // Skip page objects } // Only process valid schema type objects or strings if (typeof item === 'string') { allTypes.add(item); } else if (item && typeof item === 'object' && item.type && typeof item.type === 'string' && !item.url) { allTypes.add(item.type); } }); } // Foundation detection: Use foundation object directly (most reliable) const foundationTypes = ['Organization', 'Person', 'WebSite', 'BreadcrumbList']; let foundationPresent = 0; if (schemaData.foundation && typeof schemaData.foundation === 'object') { // Use foundation object directly - count how many are true foundationPresent = foundationTypes.filter(type => schemaData.foundation[type] === true).length; } else { // Fallback: check allTypes set foundationPresent = foundationTypes.filter(type => allTypes.has(type)).length; } const foundationScore = (foundationPresent / foundationTypes.length) * 100; const richResultTypes = ['Article', 'Event', 'FAQPage', 'Product', 'LocalBusiness', 'Course', 'Review', 'HowTo', 'VideoObject', 'ImageObject', 'ItemList']; const richEligibleCount = Object.values(schemaData.richEligible || {}).filter(eligible => eligible === true).length; const richResultScore = (richEligibleCount / richResultTypes.length) * 100; const coverageScore = schemaData.coverage || 0; const uniqueTypesCount = allTypes.size; const diversityScore = Math.min((uniqueTypesCount / 15) * 100, 100); // Get schema types list for display (properly formatted to avoid [object Object]) let schemaTypesList = []; if (schemaData.allDetectedTypes && Array.isArray(schemaData.allDetectedTypes)) { schemaTypesList = schemaData.allDetectedTypes.slice(0, 20).filter(t => t && typeof t === 'string'); } else if (schemaData.schemaTypes && Array.isArray(schemaData.schemaTypes)) { schemaTypesList = schemaData.schemaTypes .filter(t => { // Skip null/undefined if (!t) return false; // Skip page objects (have 'url' property) - these are pages, not types! if (typeof t === 'object' && ('url' in t || ('title' in t && 'metaDescription' in t))) return false; // Only keep valid type objects or strings return typeof t === 'string' || (typeof t === 'object' && t.type && typeof t.type === 'string' && !t.url); }) .map(t => { if (typeof t === 'string') return t.trim(); if (t && typeof t === 'object' && t.type && typeof t.type === 'string') { return `${t.type}${t.count ? ` (${t.count})` : ''}`; } return null; }) .filter(t => t !== null && t !== undefined && t !== '' && typeof t === 'string') .slice(0, 20); } const schemaTypesDisplay = schemaTypesList.length > 0 ? schemaTypesList.join(', ') : 'None detected'; pillarBreakdown = `
${formatComponentScore('Foundation', foundationScore)} ${formatComponentScore('Rich Results', richResultScore)} ${formatComponentScore('Coverage', coverageScore)} ${formatComponentScore('Diversity', diversityScore)}
`; pillarDetails = `
`; setTimeout(() => { const btn = document.getElementById(buttonId); const details = document.getElementById(detailsId); if (btn && details) { btn.addEventListener('click', () => { const isVisible = details.style.display !== 'none'; details.style.display = isVisible ? 'none' : 'block'; btn.textContent = isVisible ? 'Show details' : 'Hide details'; }); } }, 0); } else if (key === 'visibility' && searchDataForBreakdown) { const avgPos = searchDataForBreakdown.averagePosition || 40; const clampedPos = Math.max(1, Math.min(40, avgPos)); const scale = (clampedPos - 1) / 39; const posScore = 100 - scale * 90; pillarBreakdown = `
${formatComponentScore('Position', clampScore(posScore))} ${formatComponentScore('CTR', Math.min((searchDataForBreakdown.ctr || 0) / 0.10 * 100, 100))}
`; pillarDetails = `
`; setTimeout(() => { const btn = document.getElementById(buttonId); const details = document.getElementById(detailsId); if (btn && details) { btn.addEventListener('click', () => { const isVisible = details.style.display !== 'none'; details.style.display = isVisible ? 'none' : 'block'; btn.textContent = isVisible ? 'Show details' : 'Hide details'; }); } }, 0); } else if (key === 'localEntity' && pillarHasLocalSignals && pillarLocalSignalsData) { // DEBUG: Log locations data structure debugLog(`[Local Entity Card] pillarLocalSignalsData keys: ${Object.keys(pillarLocalSignalsData).join(', ')}`, 'info'); debugLog(`[Local Entity Card] locations type: ${typeof pillarLocalSignalsData.locations}, isArray: ${Array.isArray(pillarLocalSignalsData.locations)}, length: ${pillarLocalSignalsData.locations?.length || 0}`, 'info'); if (pillarLocalSignalsData.locations && pillarLocalSignalsData.locations.length > 0) { debugLog(`[Local Entity Card] First location: ${JSON.stringify(pillarLocalSignalsData.locations[0]).substring(0, 200)}`, 'info'); } else { debugLog(`[Local Entity Card] ⚠️ locations is missing or empty!`, 'warn'); } const napScore = pillarLocalSignalsData.napConsistencyScore || 0; const knowledgePanelScore = pillarLocalSignalsData.knowledgePanelDetected ? 100 : 0; const locationsScore = (pillarLocalSignalsData.locations?.length || 0) > 0 ? 100 : 0; pillarBreakdown = `
${formatComponentScore('NAP Consistency', napScore)} ${formatComponentScore('Knowledge Panel', knowledgePanelScore)} ${formatComponentScore('Locations', locationsScore)}
`; pillarDetails = `
`; setTimeout(() => { const btn = document.getElementById(buttonId); const details = document.getElementById(detailsId); if (btn && details) { btn.addEventListener('click', () => { const isVisible = details.style.display !== 'none'; details.style.display = isVisible ? 'none' : 'block'; btn.textContent = isVisible ? 'Show details' : 'Hide details'; }); } }, 0); } else if (key === 'serviceArea' && pillarHasLocalSignals && pillarLocalSignalsData) { const serviceAreasCount = pillarLocalSignalsData.serviceAreas?.length || 0; const serviceAreasScore = serviceAreasCount >= 8 ? 100 : Math.min(100, serviceAreasCount * 12.5); const napScore = pillarLocalSignalsData.napConsistencyScore || 0; pillarBreakdown = `
${formatComponentScore('Service Areas', serviceAreasScore)} ${formatComponentScore('NAP Consistency', napScore)}
`; pillarDetails = `
`; setTimeout(() => { const btn = document.getElementById(buttonId); const details = document.getElementById(detailsId); if (btn && details) { btn.addEventListener('click', () => { const isVisible = details.style.display !== 'none'; details.style.display = isVisible ? 'none' : 'block'; btn.textContent = isVisible ? 'Show details' : 'Hide details'; }); } }, 0); } else { // No breakdown available - add empty section for consistent spacing pillarBreakdown = `
`; pillarDetails = `
`; } } // Define pillar colors for consistency across all reports const pillarColors = { localEntity: 'rgba(147, 51, 234, 1)', // Purple serviceArea: '#00FFFF', // Cyan (not RAG color) authority: '#99004C', // Dark pink/magenta visibility: 'rgba(37, 99, 235, 1)', // Blue contentSchema: 'rgba(107, 114, 128, 1)' // Grey }; const pillarColor = pillarColors[key] || '#666'; card.innerHTML = `

${pillarNames[key]}

${Math.round(score)}
${rag.label}

${descriptions[key] || ''}

${key === 'authority' ? authorityModeToggle : '
'} ${key === 'authority' ? authorityBreakdown : pillarBreakdown}
${key === 'authority' ? authorityDetails : pillarDetails}
`; pillarCards.appendChild(card); debugLog(`✓ Added pillar card for ${key} (score: ${score})`, 'info'); }); debugLog(`✓ Created ${orderedPillars.length} pillar cards total`, 'success'); // Shared functions for Top Pages section (used by both createTopPagesSection functions) // Classification functions for metric status function classifySiteCtr(v) { // v is already a percentage (0-100), not a decimal if (v < 1) return 'poor'; // <1% if (v < 3) return 'ok'; // 1-3% return 'strong'; // 3%+ } function classifyTop10Ctr(v) { // v is already a percentage (0-100), not a decimal if (v < 2) return 'poor'; // <2% if (v < 5) return 'ok'; // 2-5% return 'strong'; // 5%+ } function classifyAvgPos(p) { if (p > 10) return 'poor'; // mostly bottom of page 1 / page 2 if (p > 5) return 'ok'; // mid-page 1 return 'strong'; // positions 1-5 } function classifyTop10Share(v) { // v is stored as decimal (0-1), needs to be converted to percentage for comparison const pct = v * 100; if (pct < 60) return 'poor'; // <60% of impressions in top 10 if (pct < 80) return 'ok'; // 60-80% return 'strong'; // 80%+ } // Helper to format percentage // For CTR values: already percentages (0-100), just format // For top10Share: stored as decimal (0-1), convert to percentage function pct(v, isDecimal = false) { if (isDecimal) { return `${(v * 100).toFixed(2)}%`; } return `${v.toFixed(2)}%`; } // Helper to extract target value from target text function extractTargetValue(targetText, metricType) { if (metricType === 'ctr' || metricType === 'percentage') { // Extract range like "2–3%+" or "75–85%+" const match = targetText.match(/(\d+(?:\.\d+)?)[–-](\d+(?:\.\d+)?)%/); if (match) { const min = parseFloat(match[1]); const max = parseFloat(match[2]); return (min + max) / 2; // Return midpoint } } else if (metricType === 'position') { // Extract range like "3–6" const match = targetText.match(/(\d+(?:\.\d+)?)[–-](\d+(?:\.\d+)?)/); if (match) { const min = parseFloat(match[1]); const max = parseFloat(match[2]); return (min + max) / 2; // Return midpoint } } return null; } // Build recommendation rows from segment metrics function buildBehaviourRankingRecommendations(segment, m) { const rows = []; // Site CTR recommendation const siteStatus = classifySiteCtr(m.siteCtr); const siteCurrent = m.siteCtr; const siteTargetText = 'Aim for 2–3%+ overall CTR'; const siteTargetValue = extractTargetValue(siteTargetText, 'ctr'); // 2.5% const siteDiff = siteTargetValue ? (siteCurrent - siteTargetValue) : null; // Keep sign for display rows.push({ metric: 'Overall CTR (all ranking queries)', status: siteStatus, value: pct(m.siteCtr), currentValue: siteCurrent, target: siteTargetText, targetValue: siteTargetValue, difference: siteDiff, action: siteStatus === 'poor' ? (segment === 'money' ? 'Rewrite titles and descriptions on the top money pages (table above). Add intent phrases ("workshop", "course", "near me") and stronger benefits to lift clicks for your money pages.' : 'Focus on high-impression pages with weak CTR in this segment. Tighten titles and descriptions so they clearly answer the search intent and highlight the benefit.') : siteStatus === 'ok' ? (segment === 'money' ? 'CTR is reasonable for your money pages. Prioritise the worst pages in the table above for A/B-style tests on titles and descriptions.' : 'CTR is reasonable for this segment. Prioritise the worst pages in the table above for A/B-style tests on titles and descriptions.') : 'CTR is strong for this segment. Keep monitoring but prioritise ranking and impression share improvements.' }); // Top-10 CTR recommendation const top10Status = classifyTop10Ctr(m.top10Ctr); const top10Current = m.top10Ctr; const top10TargetText = 'Aim for 3–5%+ in top-10'; const top10TargetValue = extractTargetValue(top10TargetText, 'ctr'); // 4% const top10Diff = top10TargetValue ? (top10Current - top10TargetValue) : null; // Keep sign for display rows.push({ metric: 'Top-10 CTR (positions 1–10)', status: top10Status, value: pct(m.top10Ctr), currentValue: top10Current, target: top10TargetText, targetValue: top10TargetValue, difference: top10Diff, action: top10Status === 'poor' ? (segment === 'money' ? 'When your money pages are already in positions 1–10 but clicks are low, re-write titles/meta to be more specific: include location, level (beginner/advanced), and outcome ("learn to…", "master…").' : 'When you are already in positions 1–10 but clicks are low, re-write titles/meta to be more specific: include location, level (beginner/advanced), and outcome ("learn to…", "master…").') : top10Status === 'ok' ? (segment === 'money' ? 'Identify top-10 money pages with below-average CTR and iterate on their SERP snippet (titles, descriptions, rich results where available).' : 'Identify top-10 pages with below-average CTR and iterate on their SERP snippet (titles, descriptions, rich results where available).') : 'Top-10 CTR is healthy. Focus on pushing more queries into the top-10 (see avg position & top-10 share).' }); // Average position recommendation const posStatus = classifyAvgPos(m.avgPosition); const posCurrent = m.avgPosition; const posTargetText = 'Aim for average position 3–6 on core queries'; const posTargetValue = extractTargetValue(posTargetText, 'position'); // 4.5 const posDiff = posTargetValue ? (posCurrent - posTargetValue) : null; // Keep sign for display (positive = worse) rows.push({ metric: 'Average position (ranking queries)', status: posStatus, value: m.avgPosition != null ? m.avgPosition.toFixed(1) : 'N/A', currentValue: posCurrent, target: posTargetText, targetValue: posTargetValue, difference: posDiff, action: posStatus === 'poor' ? (segment === 'money' ? 'Most impressions for money pages are coming from low positions. Strengthen internal links to money pages, add more supporting content, and build links from relevant blogs/assignments into these URLs.' : 'Most impressions are coming from low positions. Strengthen internal links to key pages, add more supporting content, and build links from relevant blogs/assignments into these URLs.') : posStatus === 'ok' ? (segment === 'money' ? 'Your money pages are mid-page 1 on average. Use on-page tuning (H1, sub-heads, FAQs) and internal links from strong blogs to nudge key money URLs into positions 1–3.' : 'You are mid-page 1 on average. Use on-page tuning (H1, sub-heads, FAQs) and internal links from strong blogs to nudge key URLs into positions 1–3.') : 'Positions are strong overall. Concentrate on CTR and expanding coverage to more relevant queries.' }); // Top-10 share recommendation const shareStatus = classifyTop10Share(m.top10Share); const shareCurrent = m.top10Share * 100; // Convert to percentage const shareTargetText = 'Aim for 75–85%+ of impressions in top-10'; const shareTargetValue = extractTargetValue(shareTargetText, 'percentage'); // 80% const shareDiff = shareTargetValue ? (shareCurrent - shareTargetValue) : null; // Keep sign for display rows.push({ metric: 'Top-10 impression share', status: shareStatus, value: pct(m.top10Share, true), // top10Share is stored as decimal (0-1) currentValue: shareCurrent, target: shareTargetText, targetValue: shareTargetValue, difference: shareDiff, action: shareStatus === 'poor' ? (segment === 'money' ? 'Large share of impressions for money pages are outside the top-10. Review which queries are generating impressions but no clicks and decide: improve those money pages or deliberately de-optimise low-value queries.' : 'Large share of impressions are outside the top-10. Review which queries are generating impressions but no clicks and decide: improve those pages or deliberately de-optimise low-value queries.') : shareStatus === 'ok' ? (segment === 'money' ? 'Gradually push more money page impressions into the top-10 by strengthening key hub pages (workshops, courses, tuition) and consolidating thin or overlapping content.' : 'Gradually push more impressions into the top-10 by strengthening key hub pages (workshops, courses, tuition) and consolidating thin or overlapping content.') : 'Most impressions are already top-10. Shift effort to CTR and conversion on the URLs listed above.' }); return rows; } // Render recommendations table function renderRecommendationsTable(segment, metrics, segmentLabel, dateRangeText = '30 days') { if (!metrics) { return '
No metrics available for recommendations.
'; } const recommendations = buildBehaviourRankingRecommendations(segment, metrics); // Determine priority row: poor status first, or largest gap if no poor let priorityIdx = -1; const poorRows = recommendations.map((r, i) => ({ idx: i, row: r })).filter(({ row }) => row.status === 'poor'); if (poorRows.length > 0) { // Find poor row with largest gap (use absolute value for comparison) priorityIdx = poorRows.reduce((max, curr) => { const currGap = Math.abs(curr.row.difference || 0); const maxGap = Math.abs(max.row.difference || 0); return currGap > maxGap ? curr : max; }, poorRows[0]).idx; } else { // No poor rows, find row with largest gap (use absolute value) priorityIdx = recommendations.reduce((maxIdx, row, idx) => { const currGap = Math.abs(row.difference || 0); const maxGap = Math.abs(recommendations[maxIdx]?.difference || 0); return currGap > maxGap ? idx : maxIdx; }, 0); } const getStatusPill = (status) => { const colors = { poor: { bg: '#fee2e2', text: '#991b1b', label: 'Poor' }, ok: { bg: '#fef3c7', text: '#92400e', label: 'OK' }, strong: { bg: '#d1fae5', text: '#065f46', label: 'Strong' } }; const color = colors[status] || colors.ok; return `${color.label}`; }; // Get segment display name const segmentDisplayName = segmentLabel || (segment === 'all' ? 'All pages' : segment === 'nonEducation' ? 'Exclude education (blogs / free course)' : 'Money pages only'); let tableHtml = `

Recommended actions for this segment

Segment: ${segmentDisplayName} (last ${dateRangeText})

Based on last ${dateRangeText} of Google Search Console data for the currently selected segment.

`; recommendations.forEach((row, idx) => { const isEven = idx % 2 === 0; const isPriority = idx === priorityIdx; // Determine metric type and format accordingly let currentDisplay = row.value; let targetDisplay = row.target; let diffDisplay = '—'; if (row.metric.includes('CTR')) { // CTR metrics: show as percentages (1 decimal) currentDisplay = `${row.currentValue.toFixed(1)}%`; if (row.targetValue !== null && row.targetValue !== undefined) { targetDisplay = `${row.targetValue.toFixed(1)}%`; if (row.difference !== null && row.difference !== undefined) { // Show sign and color based on status const gap = row.difference; // Already calculated as current - target if (gap >= 0) { // At or above target diffDisplay = '✓ On target'; } else { // Below target - show with sign const gapColor = row.status === 'poor' ? '#ef4444' : row.status === 'ok' ? '#f59e0b' : '#10b981'; diffDisplay = `${gap.toFixed(2)}%`; } } } } else if (row.metric.includes('position')) { // Position metrics: show as numbers (1 decimal) currentDisplay = row.currentValue.toFixed(1); if (row.targetValue !== null && row.targetValue !== undefined) { targetDisplay = row.targetValue.toFixed(1); if (row.difference !== null && row.difference !== undefined) { // For position, lower is better, so if current <= target, we're good const gap = row.difference; // Already calculated as current - target if (gap <= 0) { // At or better than target diffDisplay = '✓ On target'; } else { // Above target (worse position) - show with sign const gapColor = row.status === 'poor' ? '#ef4444' : row.status === 'ok' ? '#f59e0b' : '#10b981'; diffDisplay = `+${gap.toFixed(1)}`; } } } } else if (row.metric.includes('impression share')) { // Share metrics: show as percentages (1 decimal) currentDisplay = `${row.currentValue.toFixed(1)}%`; if (row.targetValue !== null && row.targetValue !== undefined) { targetDisplay = `${row.targetValue.toFixed(1)}%`; if (row.difference !== null && row.difference !== undefined) { // For share, higher is better const gap = row.difference; // Already calculated as current - target if (gap >= 0) { // At or above target diffDisplay = '✓ On target'; } else { // Below target - show with sign const gapColor = row.status === 'poor' ? '#ef4444' : row.status === 'ok' ? '#f59e0b' : '#10b981'; diffDisplay = `${gap.toFixed(2)}%`; } } } } // Priority row styling: left border and optional priority badge const priorityStyle = isPriority ? 'border-left: 4px solid #ef4444; background: #fef2f2;' : ''; const priorityBadge = isPriority ? 'Priority' : ''; tableHtml += ` `; }); tableHtml += `
Metric Status Current Target To Target Suggested action
${row.metric}${priorityBadge} ${getStatusPill(row.status)} ${currentDisplay} ${targetDisplay} ${diffDisplay} ${row.action}
`; return tableHtml; } // Function to render full-width top pages table (shared by both createTopPagesSection functions) function renderFullWidthTopPagesTable(topPages, segmentLabel, dateRangeText = '30 days') { if (!topPages || topPages.length === 0) { return '
No page data available for this segment. This table shows the latest snapshot from your most recent audit (static data, not affected by time period selections). If you see this message, the data may not have been computed during the last audit. Make sure your audit includes Google Search Console query+page metrics (queryPages dimension) and that you have uploaded site-urls.csv for segmentation.
'; } // Store original data for sorting if (!window.topPagesData) { window.topPagesData = topPages; window.topPagesSortColumn = null; window.topPagesSortDirection = 'desc'; } // Sort data if needed let sortedPages = [...topPages]; if (window.topPagesSortColumn) { sortedPages.sort((a, b) => { let aVal, bVal; switch(window.topPagesSortColumn) { case 'ctr': aVal = a.ctr || 0; bVal = b.ctr || 0; break; case 'impressions': aVal = a.impressions || 0; bVal = b.impressions || 0; break; case 'clicks': aVal = a.clicks || 0; bVal = b.clicks || 0; break; case 'position': aVal = a.avgPosition || 0; bVal = b.avgPosition || 0; break; default: return 0; } const diff = aVal - bVal; return window.topPagesSortDirection === 'asc' ? diff : -diff; }); } // Helper function to get sort icon (must be defined before use in template) const getSortIcon = (column) => { if (!window.topPagesSortColumn || window.topPagesSortColumn !== column) { return ''; } return window.topPagesSortDirection === 'asc' ? '' : ''; }; let tableHtml = `

Top 10 pages by impressions

Data period: Last ${dateRangeText}
`; sortedPages.forEach((page, idx) => { const isEven = idx % 2 === 0; const safeCtr = page.ctr != null ? page.ctr : 0; const safePos = page.avgPosition != null ? page.avgPosition : 0; const ctrColor = safeCtr >= 2 ? '#10b981' : safeCtr >= 1 ? '#f59e0b' : '#ef4444'; const posColor = safePos <= 5 ? '#10b981' : safePos <= 10 ? '#f59e0b' : '#ef4444'; const ctrStr = page.ctr != null ? page.ctr.toFixed(1) : 'N/A'; const posStr = page.avgPosition != null ? page.avgPosition.toFixed(1) : 'N/A'; tableHtml += ` `; }); tableHtml += `
# URL CTR${getSortIcon('ctr')} Impressions${getSortIcon('impressions')} Clicks${getSortIcon('clicks')} Avg Position${getSortIcon('position')}
${idx + 1} ${page.url} ${ctrStr}% ${page.impressions.toLocaleString()} ${page.clicks.toLocaleString()} ${page.avgPosition != null ? page.avgPosition.toFixed(1) : 'N/A'}
`; return tableHtml; } // Helper to attach sort handlers after table is in DOM (shared by both createTopPagesSection functions) function attachSortHandlers() { setTimeout(() => { ['ctr', 'impressions', 'clicks', 'position'].forEach(col => { const th = document.getElementById(`sort-${col}`); if (th) { // Remove existing listeners by cloning const newTh = th.cloneNode(true); th.parentNode.replaceChild(newTh, th); newTh.addEventListener('click', () => { if (window.handleSort) { window.handleSort(col); } else { console.error('window.handleSort is not defined'); } }); } else { console.warn(`Sort header not found: sort-${col}`); } }); }, 50); } // Make handleSort available globally (shared by both createTopPagesSection functions) if (!window.handleSort) { window.handleSort = function(column) { console.log('handleSort called with column:', column); if (window.topPagesSortColumn === column) { window.topPagesSortDirection = window.topPagesSortDirection === 'asc' ? 'desc' : 'asc'; } else { window.topPagesSortColumn = column; window.topPagesSortDirection = 'desc'; } // Get current data from stored source const currentMode = window.currentAuthorityMode || 'all'; let topPages = window.topPagesData || []; // If no stored data, try to get from authorityBySegment if (!topPages || topPages.length === 0) { const segData = window.authorityBySegment; if (segData) { topPages = currentMode === 'all' ? (segData.all?.topPages || []) : currentMode === 'nonEducation' ? (segData.nonEducation?.topPages || []) : (segData.money?.topPages || []); window.topPagesData = topPages; } } const segmentLabel = currentMode === 'all' ? 'All pages' : currentMode === 'nonEducation' ? 'Exclude education (blogs / free course)' : 'Money pages only'; // Get date range for display const dateRange = parseInt(localStorage.getItem('gsc_date_range') || '30', 10); const dateRangeText = dateRange === 30 ? '30 days' : dateRange === 60 ? '60 days' : dateRange === 90 ? '90 days' : dateRange === 120 ? '120 days' : dateRange === 180 ? '180 days' : dateRange === 365 ? '365 days' : dateRange === 540 ? '540 days' : `${dateRange} days`; const tableContainer = document.getElementById('top-pages-table-container'); if (tableContainer) { tableContainer.innerHTML = renderFullWidthTopPagesTable(topPages, segmentLabel, dateRangeText); // attachCopyButtonHandler and attachSortHandlers are in shared scope, so they're accessible attachSortHandlers(); // attachCopyButtonHandler is defined inside createTopPagesSection, so we need to find it or define it globally // For now, attach the copy button handler directly setTimeout(() => { const copyBtn = document.getElementById('top-pages-copy-urls'); if (copyBtn) { const newCopyBtn = copyBtn.cloneNode(true); copyBtn.parentNode.replaceChild(newCopyBtn, copyBtn); newCopyBtn.addEventListener('click', async () => { const currentMode = window.currentAuthorityMode || 'all'; const segData = window.authorityBySegment; const currentTopPages = currentMode === 'all' ? (segData?.all?.topPages || []) : currentMode === 'nonEducation' ? (segData?.nonEducation?.topPages || []) : (segData?.money?.topPages || []); const text = currentTopPages.map(p => p.url).join('\n'); try { await navigator.clipboard.writeText(text); newCopyBtn.textContent = 'Copied!'; newCopyBtn.style.color = '#10b981'; setTimeout(() => { newCopyBtn.textContent = 'Copy URLs'; newCopyBtn.style.color = '#666'; }, 2000); } catch (err) { console.error('Failed to copy URLs:', err); newCopyBtn.textContent = 'Copy failed'; setTimeout(() => { newCopyBtn.textContent = 'Copy URLs'; }, 2000); } }); } }, 50); } else { console.error('top-pages-table-container not found'); } }; } // Function to render segment comparison table (shared by both createTopPagesSection functions) function renderSegmentComparisonTable(authorityBySegment, currentMode) { const segments = [ { key: 'all', label: 'All pages', data: authorityBySegment.all }, { key: 'nonEducation', label: 'Exclude education (blogs / free course)', data: authorityBySegment.nonEducation }, { key: 'money', label: 'Money pages only', data: authorityBySegment.money } ]; let tableHtml = `

Segment overview (CTR & ranking)

`; segments.forEach(({ key, label, data }, idx) => { const isActive = key === currentMode; const siteCtr = data?.siteCtr || 0; const top10Ctr = data?.top10Ctr || 0; const avgPosition = data?.avgPosition || 0; const top10Share = (data?.top10Share || 0) * 100; const behaviourScore = data?.behaviour || 0; const rankingScore = data?.ranking || 0; tableHtml += ` `; }); tableHtml += `
Segment Site CTR Top-10 CTR Avg pos. Top-10 share Behaviour Ranking
${label} ${isActive ? 'current' : ''} ${siteCtr != null ? siteCtr.toFixed(1) : 'N/A'}% ${top10Ctr != null ? top10Ctr.toFixed(1) : 'N/A'}% ${avgPosition != null ? avgPosition.toFixed(1) : 'N/A'} ${top10Share != null ? top10Share.toFixed(1) : 'N/A'}% ${behaviourScore != null ? Math.round(behaviourScore) : 'N/A'} ${rankingScore != null ? Math.round(rankingScore) : 'N/A'}
`; return tableHtml; } // Function to create Top Pages section (full width, below pillar cards) function createTopPagesSection(scores, saved) { // Get Authority segment data from current scores (latest snapshot, not historical) // This uses the most recent audit data, not historical Supabase data const authorityObj = scores?.authority; let authorityBySegment = (typeof authorityObj === 'object' && authorityObj !== null) ? authorityObj.bySegment : null; // If no segment data in scores, try to get from saved audit (latest audit data from localStorage) if (!authorityBySegment && saved) { const savedScores = saved.scores; if (savedScores && savedScores.authority) { const savedAuthorityObj = savedScores.authority; if (typeof savedAuthorityObj === 'object' && savedAuthorityObj !== null) { authorityBySegment = savedAuthorityObj.bySegment || null; debugLog('📊 Top Pages: Using Authority segment data from saved audit (latest snapshot)', 'info'); } } } if (!authorityBySegment) { debugLog('⚠ No Authority segment data available for Top Pages table. This requires GSC queryPages data from your most recent audit.', 'warn'); // Still create the section but show a helpful message - don't return early } // Remove existing top pages section if it exists const existingTopPages = document.getElementById('authority-top-pages-section'); if (existingTopPages) { existingTopPages.remove(); } // Create new section const topPagesSection = document.createElement('div'); topPagesSection.id = 'authority-top-pages-section'; topPagesSection.className = 'section-break'; topPagesSection.style.marginTop = '2rem'; topPagesSection.style.marginBottom = '2rem'; // Get current mode from Authority card toggle (default to 'all') let currentMode = 'all'; const authorityCard = Array.from(document.querySelectorAll('.pillar-card')).find(card => { const h3 = card.querySelector('h3'); return h3 && h3.textContent === 'Authority'; }); if (authorityCard && authorityCard._authorityMode) { currentMode = authorityCard._authorityMode; } // Get top pages for current mode const topPages = currentMode === 'all' ? (authorityBySegment?.all?.topPages || []) : currentMode === 'nonEducation' ? (authorityBySegment?.nonEducation?.topPages || []) : (authorityBySegment?.money?.topPages || []); const segmentLabel = currentMode === 'all' ? 'All pages' : currentMode === 'nonEducation' ? 'Exclude education (blogs / free course)' : 'Money pages only'; debugLog(`📊 Top Pages: Found ${topPages.length} pages for segment "${currentMode}"`, 'info'); if (topPages.length > 0) { debugLog(`📊 Top Pages: First page URL: ${topPages[0].url}, Impressions: ${topPages[0].impressions}`, 'info'); } // Get brand queries for mini-table const topQueries = saved?.searchData?.topQueries || []; const brandQueries = topQueries .filter(q => isBrandQuery(q.query || '')) .sort((a, b) => (b.impressions || 0) - (a.impressions || 0)) .slice(0, 10); // Create section HTML topPagesSection.innerHTML = `

Authority - Behaviour & Ranking

Behaviour: Measures click-through rate (CTR) performance. Combines Overall CTR (50% weight) for all ranking search terms and Top-10 Ranked Search Terms CTR (50% weight) for queries ranking in positions 1-10. Indicates how well your titles and descriptions convert impressions to clicks. Data source: Google Search Console query+page metrics.

Ranking: Measures search visibility and position quality. Combines Average Position Score (50% weight) and Top-10 Impression Share (50% weight). Shows how high you rank on average and what percentage of impressions appear in positions 1-10. Data source: Google Search Console query+page metrics.

Current Segment: ${segmentLabel}
${renderFullWidthTopPagesTable(topPages, segmentLabel, dateRangeText)}
${authorityBySegment && authorityBySegment[currentMode] ? `
${renderRecommendationsTable(currentMode, { siteCtr: authorityBySegment[currentMode].siteCtr || 0, top10Ctr: authorityBySegment[currentMode].top10Ctr || 0, avgPosition: authorityBySegment[currentMode].avgPosition || 0, top10Share: authorityBySegment[currentMode].top10Share || 0, behaviourScore: authorityBySegment[currentMode].behaviour || 0, rankingScore: authorityBySegment[currentMode].ranking || 0 }, segmentLabel, dateRangeText)}
` : ''} ${brandQueries.length > 0 ? `

Top Branded Queries

Branded search queries (e.g., "Alan Ranger Photography") with CTR and position metrics.

${brandQueries.map((q, idx) => { const ctr = q.impressions > 0 ? ((q.clicks || 0) / q.impressions * 100) : 0; const ctrColor = ctr >= 25 ? '#10b981' : ctr >= 10 ? '#f59e0b' : '#ef4444'; const posColor = (q.position || 0) <= 3 ? '#10b981' : (q.position || 0) <= 5 ? '#f59e0b' : '#ef4444'; return ` `; }).join('')}
Query Impressions Clicks CTR Position
${(q.query || '').replace(//g, '>')} ${(q.impressions || 0).toLocaleString()} ${(q.clicks || 0).toLocaleString()} ${ctr.toFixed(1)}% ${(q.position || 0).toFixed(1)}
` : ''}
`; // Insert into Authority panel instead of Overview const authorityPanel = document.querySelector('.aigeo-panel[data-panel="authority"]'); if (authorityPanel) { // Clear any existing content const existing = document.getElementById('authority-top-pages-section'); if (existing) existing.remove(); authorityPanel.appendChild(topPagesSection); debugLog('✓ Authority section inserted into Authority panel', 'success'); } else { // Fallback: insert after pillar cards (old behavior) pillarCards.parentNode.insertBefore(topPagesSection, pillarCards.nextSibling); debugLog('✓ Authority section inserted after pillar cards (fallback)', 'info'); } // Store authorityBySegment globally so updateTopPagesSection can access it window.authorityBySegment = authorityBySegment; // Store update function globally so Authority mode toggle can call it window.updateTopPagesSection = function(mode) { const topPages = mode === 'all' ? (authorityBySegment?.all?.topPages || []) : mode === 'nonEducation' ? (authorityBySegment?.nonEducation?.topPages || []) : (authorityBySegment?.money?.topPages || []); const segmentLabel = mode === 'all' ? 'All pages' : mode === 'nonEducation' ? 'Exclude education (blogs / free course)' : 'Money pages only'; debugLog(`📊 Top Pages: Updating to segment "${mode}", found ${topPages.length} pages`, 'info'); // Update segment label const labelEl = document.getElementById('top-pages-segment-label'); if (labelEl) labelEl.textContent = segmentLabel; // Update summary if available const getSegmentSummary = (m) => { if (!authorityBySegment || !authorityBySegment[m]) return null; const segmentData = authorityBySegment[m]; return { behaviour: segmentData.behaviour || 0, ranking: segmentData.ranking || 0, total: segmentData.total || segmentData.score || 0 }; }; const summary = getSegmentSummary(mode); const summaryDiv = document.getElementById('top-pages-segment-summary'); if (summary && summaryDiv) { const rag = getRAGStatus(summary.total); summaryDiv.innerHTML = ` ${formatComponentScore('Behaviour', summary.behaviour)} ${formatComponentScore('Ranking', summary.ranking)} `; // Update RAG badge and score const ragBadge = summaryDiv.parentElement.querySelector('.rag-badge'); const scoreSpan = summaryDiv.parentElement.querySelector('span[style*="font-size: 1.5rem"]'); if (ragBadge) { ragBadge.className = `rag-badge ${rag.status}`; ragBadge.textContent = rag.label; } if (scoreSpan) { scoreSpan.textContent = Math.round(summary.total); scoreSpan.style.color = rag.status === 'green' ? '#10b981' : rag.status === 'amber' ? '#f59e0b' : '#ef4444'; } } // Update toggle buttons ['all', 'nonEducation', 'money'].forEach(m => { const btn = document.getElementById(`top-pages-mode-${m}`); if (btn) { if (m === mode) { btn.style.background = '#10b981'; btn.style.color = 'white'; btn.style.fontWeight = '600'; } else { btn.style.background = 'white'; btn.style.color = '#666'; btn.style.fontWeight = '400'; } } }); // Update comparison table const comparisonDiv = document.getElementById('top-pages-comparison-table'); if (comparisonDiv && authorityBySegment) { comparisonDiv.innerHTML = renderSegmentComparisonTable(authorityBySegment, mode); } // Get date range for display const dateRange = parseInt(localStorage.getItem('gsc_date_range') || '30', 10); const dateRangeText = dateRange === 30 ? '30 days' : dateRange === 60 ? '60 days' : dateRange === 90 ? '90 days' : dateRange === 120 ? '120 days' : dateRange === 180 ? '180 days' : dateRange === 365 ? '365 days' : dateRange === 540 ? '540 days' : `${dateRange} days`; // Update table (reset sort when switching segments) window.topPagesData = topPages; window.topPagesSortColumn = null; window.topPagesSortDirection = 'desc'; const tableContainer = document.getElementById('top-pages-table-container'); if (tableContainer) { tableContainer.innerHTML = renderFullWidthTopPagesTable(topPages, segmentLabel, dateRangeText); attachCopyButtonHandler(); attachSortHandlers(); } // Update recommendations table const recommendationsContainer = document.getElementById('top-pages-recommendations-container'); if (recommendationsContainer && authorityBySegment && authorityBySegment[mode]) { const segmentLabel = mode === 'all' ? 'All pages' : mode === 'nonEducation' ? 'Exclude education (blogs / free course)' : 'Money pages only'; const dateRange = parseInt(localStorage.getItem('gsc_date_range') || '30', 10); const dateRangeText = dateRange === 30 ? '30 days' : dateRange === 60 ? '60 days' : dateRange === 90 ? '90 days' : dateRange === 120 ? '120 days' : dateRange === 180 ? '180 days' : dateRange === 365 ? '365 days' : dateRange === 540 ? '540 days' : `${dateRange} days`; recommendationsContainer.innerHTML = renderRecommendationsTable(mode, { siteCtr: authorityBySegment[mode].siteCtr || 0, top10Ctr: authorityBySegment[mode].top10Ctr || 0, avgPosition: authorityBySegment[mode].avgPosition || 0, top10Share: authorityBySegment[mode].top10Share || 0, behaviourScore: authorityBySegment[mode].behaviour || 0, rankingScore: authorityBySegment[mode].ranking || 0 }, segmentLabel, dateRangeText); } }; // window.handleSort is now defined in shared scope above // Attach toggle button handlers setTimeout(() => { ['all', 'nonEducation', 'money'].forEach(mode => { const btn = document.getElementById(`top-pages-mode-${mode}`); if (btn) { btn.addEventListener('click', () => { window.currentAuthorityMode = mode; window.updateTopPagesSection(mode); // Also update Authority pillar card if it exists const authorityCard = Array.from(document.querySelectorAll('.pillar-card')).find(card => { const h3 = card.querySelector('h3'); return h3 && h3.textContent === 'Authority'; }); if (authorityCard && authorityCard._updateAuthorityDisplay) { authorityCard._authorityMode = mode; authorityCard._updateAuthorityDisplay(); // Update Authority pillar toggle buttons const modeId = authorityCard._modeId; ['all', 'nonEducation', 'money'].forEach(m => { const authBtn = document.getElementById(`${modeId}-${m}`); if (authBtn) { if (m === mode) { authBtn.style.background = '#10b981'; authBtn.style.color = 'white'; } else { authBtn.style.background = 'white'; authBtn.style.color = '#666'; } } }); } }); } }); }, 0); // Attach initial copy button handler attachCopyButtonHandler(); function attachCopyButtonHandler() { setTimeout(() => { const copyBtn = document.getElementById('top-pages-copy-urls'); if (copyBtn) { // Remove existing listener const newCopyBtn = copyBtn.cloneNode(true); copyBtn.parentNode.replaceChild(newCopyBtn, copyBtn); newCopyBtn.addEventListener('click', async () => { const currentMode = window.currentAuthorityMode || 'all'; const currentTopPages = currentMode === 'all' ? (authorityBySegment?.all?.topPages || []) : currentMode === 'nonEducation' ? (authorityBySegment?.nonEducation?.topPages || []) : (authorityBySegment?.money?.topPages || []); const text = currentTopPages.map(p => p.url).join('\n'); try { await navigator.clipboard.writeText(text); newCopyBtn.textContent = 'Copied!'; newCopyBtn.style.color = '#10b981'; setTimeout(() => { newCopyBtn.textContent = 'Copy URLs'; newCopyBtn.style.color = '#666'; }, 2000); } catch (err) { console.error('Failed to copy URLs:', err); newCopyBtn.textContent = 'Copy failed'; setTimeout(() => { newCopyBtn.textContent = 'Copy URLs'; }, 2000); } }); } }, 0); } // renderFullWidthTopPagesTable is now defined in shared scope above } // Helper to set sort and re-apply filters from anywhere (header clicks) window.moneyPagesSetSort = function(column) { if (window.moneyPagesSortColumn === column) { window.moneyPagesSortDirection = window.moneyPagesSortDirection === 'asc' ? 'desc' : 'asc'; } else { window.moneyPagesSortColumn = column; window.moneyPagesSortDirection = 'asc'; } window.moneyPagesCurrentPage = 1; if (typeof window.moneyPagesApplyFilters === 'function') { window.moneyPagesApplyFilters(); } }; // Show inline performance cards for Money Pages rows (replaces modal) if (typeof window.showMoneyPagePerformanceInline !== 'function') { window.showMoneyPagePerformanceInline = async function(pageUrl, pageTitle, rowElement) { try { if (!window.moneyPagesRowDataByUrl || window.moneyPagesRowDataByUrl.size === 0) { alert('No page data available yet. Please wait for the table to finish loading.'); return; } const normalized = normalizeUrlForDedupe ? normalizeUrlForDedupe(pageUrl) : pageUrl.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, ''); const row = window.moneyPagesRowDataByUrl.get(normalized); if (!row) { alert('Page data not found yet. Please try again after the table loads.'); return; } // Find the row element if not provided let currentRow = rowElement; if (!currentRow) { const allRows = document.querySelectorAll('#money-pages-table-container tbody tr'); currentRow = Array.from(allRows).find(tr => { const aiCell = tr.querySelector('.ai-citation-cell-opportunity'); if (aiCell) { const cellUrl = aiCell.getAttribute('data-page-url'); return cellUrl === pageUrl; } return false; }); } if (!currentRow) { console.error('[Money Pages] Could not find row element for inline cards'); return; } // Check if cards already exist for this row (check next sibling row) const nextRow = currentRow.nextElementSibling; if (nextRow && nextRow.querySelector('.money-page-inline-cards')) { // Toggle: remove if already showing nextRow.remove(); return; } // Normalize URL helper (same as API endpoint) const normalizeUrl = (url) => { if (!url) return ''; let normalized = String(url).toLowerCase().trim(); normalized = normalized.replace(/^https?:\/\//, ''); normalized = normalized.replace(/^www\./, ''); normalized = normalized.split('?')[0].split('#')[0]; const parts = normalized.split('/'); if (parts.length > 1) { normalized = parts.slice(1).join('/'); } normalized = normalized.replace(/^\/+/, '').replace(/\/+$/, ''); return normalized; }; const targetUrlNormalized = normalizeUrl(pageUrl); // ============================================ // CARD 1: Performance Trends (28d + 90d average from Supabase) // ============================================ const clicks28d = row.clicks || 0; const impressions28d = row.impressions || 0; const ctr28d = impressions28d > 0 ? (clicks28d / impressions28d) * 100 : ((row.ctr || 0) * 100); const pos28d = row.avgPosition != null ? row.avgPosition : (row.position || null); // Fetch 90-day average from Supabase historical audits let clicks90d = null, impressions90d = null, ctr90d = null, pos90d = null; let trendData = null; let historicalError = null; try { const propertyUrl = document.getElementById('propertyUrl')?.value || localStorage.getItem('property_url') || ''; if (propertyUrl) { // Fetch historical money pages data from Supabase (last 90 days) const apiEndpoint = apiUrl(`/api/supabase/money-pages-historical?property_url=${encodeURIComponent(propertyUrl)}&target_url=${encodeURIComponent(pageUrl)}&days=90`); console.log('[Money Pages Card 1] Fetching 90-day data from:', apiEndpoint); const response = await fetch(apiEndpoint); console.log('[Money Pages Card 1] Response status:', response.status); if (response.ok) { const historicalData = await response.json(); console.log('[Money Pages Card 1] Historical data response:', historicalData); if (historicalData.status === 'ok' && historicalData.data) { clicks90d = historicalData.data.clicks_90d || null; impressions90d = historicalData.data.impressions_90d || null; ctr90d = historicalData.data.ctr_90d || null; pos90d = historicalData.data.avg_position_90d || null; console.log('[Money Pages Card 1] Parsed 90-day data:', { clicks90d, impressions90d, ctr90d, pos90d }); if (clicks90d !== null && impressions90d !== null) { trendData = { clicks28d: clicks28d, clicks90d: clicks90d, clicksChange: clicks90d - clicks28d, impressions28d: impressions28d, impressions90d: impressions90d, impressionsChange: impressions90d - impressions28d, ctr28d: ctr28d, ctr90d: ctr90d || (impressions90d > 0 ? (clicks90d / impressions90d) * 100 : 0), pos28d: pos28d, pos90d: pos90d }; console.log('[Money Pages Card 1] Trend data calculated:', trendData); } else { console.warn('[Money Pages Card 1] 90-day data missing clicks or impressions'); } } else { console.warn('[Money Pages Card 1] API returned non-ok status or missing data:', historicalData); historicalError = historicalData.message || historicalData.error || 'No data available'; } } else { let errorText = 'Unknown error'; try { errorText = await response.text(); } catch (e) { // Ignore } console.warn('[Money Pages Card 1] API request failed:', response.status, errorText); historicalError = `API error: ${response.status}`; // Fallback: Try to get from timeseries in localStorage const lastAuditResults = localStorage.getItem('last_audit_results'); if (lastAuditResults) { const auditData = JSON.parse(lastAuditResults); if (auditData.gscData && auditData.gscData.timeseries) { const timeseries = auditData.gscData.timeseries; if (timeseries.pages && Array.isArray(timeseries.pages)) { const pageData = timeseries.pages.find(p => { const pUrl = normalizeUrl(p.page || p.url || ''); return pUrl === targetUrlNormalized; }); if (pageData && pageData.data && Array.isArray(pageData.data)) { const now = new Date(); const days90Ago = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); let clicks90 = 0, impressions90 = 0; let positionSum90 = 0, positionCount90 = 0; pageData.data.forEach(point => { const pointDate = new Date(point.date || point.day); if (pointDate >= days90Ago) { clicks90 += point.clicks || 0; impressions90 += point.impressions || 0; if (point.position != null && point.impressions > 0) { positionSum90 += point.position * point.impressions; positionCount90 += point.impressions; } } }); if (impressions90 > 0) { clicks90d = clicks90; impressions90d = impressions90; ctr90d = (clicks90 / impressions90) * 100; pos90d = positionCount90 > 0 ? positionSum90 / positionCount90 : null; trendData = { clicks28d: clicks28d, clicks90d: clicks90, clicksChange: clicks90 - clicks28d, impressions28d: impressions28d, impressions90d: impressions90, impressionsChange: impressions90 - impressions28d, ctr28d: ctr28d, ctr90d: ctr90d, pos28d: pos28d, pos90d: pos90d }; } } } } } } } } catch (e) { console.warn('[Money Pages] Error getting 90-day historical data:', e); } // ============================================ // CARD 2: AI Citations with Keywords & Search Volumes // ============================================ // Use the same API endpoint that the table uses to get accurate data let citingKeywords = []; try { const propertyUrl = document.getElementById('propertyUrl')?.value || localStorage.getItem('property_url') || ''; if (propertyUrl) { // Use the same API endpoint as populateMoneyPagesAiCitations const apiResponse = await fetch(apiUrl(`/api/supabase/query-keywords-citing-url?property_url=${encodeURIComponent(propertyUrl)}&target_url=${encodeURIComponent(pageUrl)}`)); if (apiResponse.ok) { const apiData = await apiResponse.json(); if (apiData.status === 'ok' && Array.isArray(apiData.data)) { // Get search volumes from combinedRows in localStorage const lastAuditResults = localStorage.getItem('last_audit_results'); let combinedRows = []; if (lastAuditResults) { const auditData = JSON.parse(lastAuditResults); if (auditData?.ranking_ai_data?.combinedRows) { combinedRows = auditData.ranking_ai_data.combinedRows; } else if (auditData?.searchData?.ranking_ai_data?.combinedRows) { combinedRows = auditData.searchData.ranking_ai_data.combinedRows; } else if (Array.isArray(auditData?.ranking_ai_data)) { combinedRows = auditData.ranking_ai_data; } else if (Array.isArray(auditData?.searchData?.ranking_ai_data)) { combinedRows = auditData.searchData.ranking_ai_data; } } // Create lookup map for search volumes const keywordLookup = new Map(); if (Array.isArray(combinedRows)) { combinedRows.forEach(kwRow => { if (kwRow.keyword) { keywordLookup.set(kwRow.keyword.toLowerCase(), { searchVolume: kwRow.searchVolume || kwRow.volume || kwRow.monthly_search_volume || null, bestRank: kwRow.best_rank || kwRow.rank || null, hasAiOverview: kwRow.has_ai_overview || false }); } }); } // Map API response to keywords with search volumes // API now returns search_volume directly, but also check combinedRows as fallback apiData.data.forEach(kw => { const keyword = kw.keyword || ''; if (keyword) { const lookup = keywordLookup.get(keyword.toLowerCase()) || {}; // Prefer API response search_volume, fallback to combinedRows lookup const searchVolume = kw.search_volume !== undefined && kw.search_volume !== null ? kw.search_volume : (lookup.searchVolume || null); citingKeywords.push({ keyword: keyword, searchVolume: searchVolume, bestRank: kw.best_rank || kw.best_rank_group || lookup.bestRank || null, hasAiOverview: kw.has_ai_overview !== undefined ? kw.has_ai_overview : lookup.hasAiOverview || false }); } }); } } } } catch (e) { console.warn('[Money Pages] Error getting AI citation keywords:', e); } // Sort by search volume (descending), then by keyword citingKeywords.sort((a, b) => { const volDiff = (b.searchVolume || 0) - (a.searchVolume || 0); return volDiff !== 0 ? volDiff : (a.keyword || '').localeCompare(b.keyword || ''); }); // ============================================ // CARD 3: Competitor Ranking (placeholder for now) // ============================================ let competitorData = null; // TODO: Implement competitor data fetching when available // ============================================ // Build Cards HTML // ============================================ const cardsContainer = document.createElement('td'); cardsContainer.colSpan = 13; cardsContainer.className = 'money-page-inline-cards'; cardsContainer.style.cssText = 'padding: 1.25rem; background: #f8fafc; border-top: 2px solid #e2e8f0;'; // Card 1: Performance Trends let card1Html = `
Performance Trends
Last 28 days
Clicks: ${clicks28d.toLocaleString()}
Impressions: ${impressions28d.toLocaleString()}
CTR: ${ctr28d.toFixed(2)}%
Position: ${pos28d ? pos28d.toFixed(1) : '—'}
`; if (trendData && clicks90d !== null) { const clicksChange = trendData.clicksChange; const impressionsChange = trendData.impressionsChange; const ctrChange = trendData.ctr28d - trendData.ctr90d; const posChange = trendData.pos28d && trendData.pos90d ? trendData.pos90d - trendData.pos28d : 0; card1Html += `
90-day average
Clicks: ${clicks90d.toLocaleString()} (${clicksChange >= 0 ? '+' : ''}${clicksChange.toLocaleString()} vs 28d)
Impressions: ${impressions90d.toLocaleString()} (${impressionsChange >= 0 ? '+' : ''}${impressionsChange.toLocaleString()})
CTR: ${ctr90d.toFixed(2)}% (${ctrChange >= 0 ? '+' : ''}${ctrChange.toFixed(2)}% vs 28d)
Position: ${pos90d ? pos90d.toFixed(1) : '—'} ${posChange < 0 ? '↑' : posChange > 0 ? '↓' : ''} ${posChange !== 0 ? Math.abs(posChange).toFixed(1) : ''}
`; } else { let fallbackMessage = '90-day average will appear when historical audit data is available in Supabase'; if (historicalError) { fallbackMessage = `90-day data unavailable: ${historicalError}`; } card1Html += `
${fallbackMessage}
`; } card1Html += `
`; // Card 2: AI Citations with Keywords let card2Html = `
AI Citations
`; if (citingKeywords.length > 0) { card2Html += `
${citingKeywords.length} keyword${citingKeywords.length !== 1 ? 's' : ''} citing this URL
`; citingKeywords.slice(0, 10).forEach(kw => { const volume = kw.searchVolume ? kw.searchVolume.toLocaleString() : '—'; card2Html += `
${kw.keyword}
${kw.bestRank ? `
Rank: #${kw.bestRank}
` : ''}
${volume} vol
`; }); if (citingKeywords.length > 10) { card2Html += `
+ ${citingKeywords.length - 10} more keywords
`; } card2Html += `
`; } else { card2Html += `
No AI citations found for this URL in the latest audit
`; } card2Html += `
`; // Card 3: Recommended Actions // Generate recommendations based on Card 1 (performance) and Card 2 (AI citations) // Priority: Performance issues first, then AI citation opportunities const recommendations = []; // ============================================ // PERFORMANCE-BASED RECOMMENDATIONS (Priority 1) // ============================================ // Check for declining clicks/impressions if (trendData && trendData.clicksChange < 0) { const declinePercent = clicks28d > 0 ? Math.abs((trendData.clicksChange / clicks28d) * 100) : 0; if (declinePercent >= 20) { recommendations.push({ priority: 'High', title: 'Traffic Declining', nextSteps: 'Refresh content, build backlinks, or check for technical issues', estimatedImpact: `Could recover ${Math.abs(trendData.clicksChange).toLocaleString()} clicks/month if trend reverses`, reason: `Clicks down ${declinePercent.toFixed(0)}% vs 90-day average` }); } else if (declinePercent >= 10) { recommendations.push({ priority: 'Medium', title: 'Traffic Slightly Declining', nextSteps: 'Monitor closely and consider content updates or link building', estimatedImpact: `Could recover ${Math.abs(trendData.clicksChange).toLocaleString()} clicks/month`, reason: `Clicks down ${declinePercent.toFixed(0)}% vs 90-day average` }); } } // Check for low CTR if (ctr28d < 2.0 && impressions28d > 100) { const ctrGap = 2.0 - ctr28d; const potentialClicks = Math.round(impressions28d * (ctrGap / 100)); recommendations.push({ priority: ctr28d < 1.0 ? 'High' : 'Medium', title: 'Low Click-Through Rate', nextSteps: 'Optimize title tags and meta descriptions to improve CTR', estimatedImpact: `Could gain ~${potentialClicks.toLocaleString()} additional clicks/month with better CTR`, reason: `Current CTR ${ctr28d.toFixed(2)}% is below 2% benchmark` }); } // Check for position issues if (pos28d && pos28d > 10 && impressions28d > 100) { const positionGap = pos28d - 10; recommendations.push({ priority: pos28d > 20 ? 'High' : 'Medium', title: 'Ranking Below Top 10', nextSteps: 'Improve on-page SEO, build authority, or optimize for target keywords', estimatedImpact: `Moving to top 10 could increase clicks by 50-100%`, reason: `Current position ${pos28d.toFixed(1)} is outside top 10` }); } // Check for declining position if (trendData && trendData.pos28d && trendData.pos90d && trendData.pos28d > trendData.pos90d) { const positionDecline = trendData.pos28d - trendData.pos90d; if (positionDecline >= 3) { recommendations.push({ priority: 'High', title: 'Position Declining', nextSteps: 'Check for content quality issues, competitor changes, or algorithm updates', estimatedImpact: `Could recover ${positionDecline.toFixed(1)} positions with optimization`, reason: `Position dropped ${positionDecline.toFixed(1)} positions vs 90-day average` }); } } // Check for improving trends (positive reinforcement) if (trendData && trendData.clicksChange > 0 && trendData.clicksChange > clicks28d * 0.1) { recommendations.push({ priority: 'Low', title: 'Traffic Improving', nextSteps: 'Continue current strategy and double down on what\'s working', estimatedImpact: 'Maintain growth trajectory', reason: `Clicks up ${((trendData.clicksChange / clicks28d) * 100).toFixed(0)}% vs 90-day average` }); } // ============================================ // AI CITATION RECOMMENDATIONS (Priority 2) // ============================================ // Check for missing AI citations if (citingKeywords.length === 0) { recommendations.push({ priority: 'Medium', title: 'No AI Citations', nextSteps: 'Add structured data (Schema.org) and optimize content for AI overview eligibility', estimatedImpact: 'Could capture AI overview features for high-value keywords', reason: 'This URL has no AI citations in search results' }); } else { // Check for high search volume keywords that could benefit from better rankings const highVolumeKeywords = citingKeywords.filter(kw => kw.searchVolume && kw.searchVolume >= 1000); const lowRankingHighVolume = highVolumeKeywords.filter(kw => kw.bestRank && kw.bestRank > 10); if (lowRankingHighVolume.length > 0) { const totalVolume = lowRankingHighVolume.reduce((sum, kw) => sum + (kw.searchVolume || 0), 0); recommendations.push({ priority: 'Medium', title: 'High-Value Keywords Below Top 10', nextSteps: `Optimize content for "${lowRankingHighVolume[0].keyword}" and similar high-volume terms`, estimatedImpact: `Could capture ${Math.round(totalVolume * 0.1).toLocaleString()}+ monthly impressions by ranking in top 10`, reason: `${lowRankingHighVolume.length} high-volume keyword${lowRankingHighVolume.length > 1 ? 's' : ''} ranking below position 10` }); } // Check if we have citations but they're for low-volume keywords const lowVolumeKeywords = citingKeywords.filter(kw => !kw.searchVolume || kw.searchVolume < 100); if (lowVolumeKeywords.length > citingKeywords.length * 0.5) { recommendations.push({ priority: 'Low', title: 'Focus on Higher Volume Keywords', nextSteps: 'Expand content to target keywords with 100+ monthly search volume', estimatedImpact: 'Could increase potential traffic by targeting higher-volume opportunities', reason: `Most citations are for low-volume keywords (<100 searches/month)` }); } } // Sort recommendations: High priority first, then by type (performance before citations) recommendations.sort((a, b) => { const priorityOrder = { 'High': 3, 'Medium': 2, 'Low': 1 }; const priorityDiff = priorityOrder[b.priority] - priorityOrder[a.priority]; if (priorityDiff !== 0) return priorityDiff; // If same priority, performance recommendations come first const aIsPerformance = a.title.includes('Traffic') || a.title.includes('CTR') || a.title.includes('Position') || a.title.includes('Ranking'); const bIsPerformance = b.title.includes('Traffic') || b.title.includes('CTR') || b.title.includes('Position') || b.title.includes('Ranking'); if (aIsPerformance && !bIsPerformance) return -1; if (!aIsPerformance && bIsPerformance) return 1; return 0; }); // Limit to top 3 recommendations const topRecommendations = recommendations.slice(0, 3); // Build Card 3 HTML let card3Html = `
Recommended Actions
`; if (topRecommendations.length > 0) { topRecommendations.forEach((rec, idx) => { const priorityColors = { 'High': { bg: '#fee2e2', border: '#ef4444', text: '#991b1b', badge: '#ef4444' }, 'Medium': { bg: '#fef3c7', border: '#f59e0b', text: '#92400e', badge: '#f59e0b' }, 'Low': { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af', badge: '#3b82f6' } }; const colors = priorityColors[rec.priority] || priorityColors['Medium']; card3Html += `
${rec.priority}
${rec.title}
Next steps: ${rec.nextSteps}
${rec.estimatedImpact}
${rec.reason}
`; }); } else { card3Html += `
No specific recommendations at this time. Performance metrics look good!
`; } card3Html += `
`; cardsContainer.innerHTML = `
${card1Html} ${card2Html} ${card3Html}
`; // Insert after the current row if (currentRow) { const newRow = document.createElement('tr'); newRow.appendChild(cardsContainer); currentRow.parentNode.insertBefore(newRow, currentRow.nextSibling); } } catch (err) { console.error('[Money Pages] Error showing inline performance cards:', err); alert('Unable to show performance cards. See console for details.'); } }; } // Lightweight performance cards for Money Pages rows (kept globally so buttons don't error) - MODAL VERSION (kept for backward compatibility) if (typeof window.showMoneyPagePerformance !== 'function') { window.showMoneyPagePerformance = function(pageUrl, pageTitle) { try { if (!window.moneyPagesRowDataByUrl || window.moneyPagesRowDataByUrl.size === 0) { alert('No page data available yet. Please wait for the table to finish loading.'); return; } const normalized = normalizeUrlForDedupe ? normalizeUrlForDedupe(pageUrl) : pageUrl.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, ''); const row = window.moneyPagesRowDataByUrl.get(normalized); if (!row) { alert('Page data not found yet. Please try again after the table loads.'); return; } let modal = document.getElementById('money-page-performance-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'money-page-performance-modal'; modal.style.cssText = 'position: fixed; inset: 0; background: rgba(15,23,42,0.45); display: flex; align-items: center; justify-content: center; z-index: 9999;'; modal.innerHTML = `
`; document.body.appendChild(modal); modal.querySelector('#mp-perf-close').onclick = () => { modal.remove(); }; modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); } const clicks = row.clicks || 0; const impressions = row.impressions || 0; const ctr = impressions > 0 ? (clicks / impressions) * 100 : ((row.ctr || 0) * 100); const pos = row.avgPosition != null ? row.avgPosition : (row.position || null); const ai = typeof row._aiCitations === 'number' ? row._aiCitations : '—'; const recommendation = row.recommendation || 'No recommendation available'; const schema = (row.schemaTypes || []).join(', ') || 'None'; modal.querySelector('#mp-perf-title').textContent = pageTitle || row.title || row.url || 'Money Page'; modal.querySelector('#mp-perf-url').textContent = row.url || ''; modal.querySelector('#mp-perf-body').innerHTML = `
Clicks (28d)
${clicks.toLocaleString()}
Impressions (28d)
${impressions.toLocaleString()}
CTR (28d)
${ctr.toFixed(2)}%
Avg position
${pos ? pos.toFixed(1) : '—'}
AI citations
${ai}
`; modal.querySelector('#mp-perf-meta').innerHTML = `
Schema: ${schema}
Recommendation: ${recommendation}
`; } catch (err) { console.error('[Money Pages] Error opening performance view:', err); alert('Unable to open performance cards. See console for details.'); } }; } // Function to render Money Pages table (with pagination and sorting, matching Top Pages format) async function renderMoneyPagesTable(moneyRows, currentPage = 1, rowsPerPage = 10) { try { if (!moneyRows || moneyRows.length === 0) { return '
No money-page data available for this audit period. Check that your money pages have impressions in the selected date range.
'; } // Store original data for sorting (across all pages) - update if new data provided window.moneyPagesData = moneyRows; if (window.moneyPagesSortColumn === undefined) { window.moneyPagesSortColumn = null; window.moneyPagesSortDirection = 'desc'; } // Sort data if needed (sort ALL rows, not just current page) // CRITICAL: For AI citations sorting, ensure we read from cells if _aiCitations not set let sortedRows = [...moneyRows]; if (window.moneyPagesSortColumn) { // If sorting by AI citations, populate from cells first if needed if (window.moneyPagesSortColumn === 'aiCitations') { const tableContainer = document.getElementById('money-pages-table-container'); if (tableContainer) { const cells = tableContainer.querySelectorAll('.ai-citation-cell-opportunity'); cells.forEach(cell => { const cellUrl = cell.getAttribute('data-page-url'); if (cellUrl) { const cellText = cell.textContent.trim(); // Parse count from cell text (handles "⏳", "0", "1,234", etc.) let count = 0; if (cellText !== '⏳' && cellText !== '') { const parsed = parseInt(cellText.replace(/,/g, ''), 10); if (!isNaN(parsed)) { count = parsed; } } // Find matching row and update _aiCitations const matchingRow = sortedRows.find(r => { const rowUrl = r.url || ''; const rowUrlNormalized = normalizeUrl(rowUrl); const cellUrlNormalized = normalizeUrl(cellUrl); return rowUrl === cellUrl || rowUrlNormalized === cellUrlNormalized || rowUrlNormalized.includes(cellUrlNormalized) || cellUrlNormalized.includes(rowUrlNormalized); }); if (matchingRow) { matchingRow._aiCitations = count; } } }); } } sortedRows.sort((a, b) => { let aVal, bVal; switch(window.moneyPagesSortColumn) { case 'ctr': aVal = (a.ctr || 0) * 100; bVal = (b.ctr || 0) * 100; break; case 'type': { const order = { PRODUCT: 0, EVENT: 1, LANDING: 2 }; const aType = String(a.subSegment || a.segmentType || '').toUpperCase(); const bType = String(b.subSegment || b.segmentType || '').toUpperCase(); aVal = order[aType] ?? 99; bVal = order[bType] ?? 99; break; } case 'impressions': aVal = a.impressions || 0; bVal = b.impressions || 0; break; case 'clicks': aVal = a.clicks || 0; bVal = b.clicks || 0; break; case 'clickPotential': { const aImpr = Number(a.impressions || 0) || 0; const bImpr = Number(b.impressions || 0) || 0; const aClk = Number(a.clicks || 0) || 0; const bClk = Number(b.clicks || 0) || 0; aVal = Math.max(0, (aImpr * 0.025) - aClk); bVal = Math.max(0, (bImpr * 0.025) - bClk); break; } case 'position': aVal = a.avgPosition || 99; bVal = b.avgPosition || 99; break; case 'aiCitations': // Use _aiCitations if available, otherwise try to get from citationCache aVal = typeof a._aiCitations === 'number' ? a._aiCitations : (window.moneyPagesCitationCache && window.moneyPagesCitationCache[a.url] !== undefined ? window.moneyPagesCitationCache[a.url] : -1); bVal = typeof b._aiCitations === 'number' ? b._aiCitations : (window.moneyPagesCitationCache && window.moneyPagesCitationCache[b.url] !== undefined ? window.moneyPagesCitationCache[b.url] : -1); break; case 'opportunity': // Sort by category priority: HIGH_OPPORTUNITY=0, VISIBILITY_FIX=1, MAINTAIN=2 const categoryOrder = { HIGH_OPPORTUNITY: 0, VISIBILITY_FIX: 1, MAINTAIN: 2 }; aVal = categoryOrder[a.category] ?? 99; bVal = categoryOrder[b.category] ?? 99; break; default: return 0; } const diff = aVal - bVal; return window.moneyPagesSortDirection === 'asc' ? diff : -diff; }); } // Update stored sorted data window.moneyPagesData = sortedRows; // Helper function to get sort icon const getSortIcon = (column) => { if (!window.moneyPagesSortColumn || window.moneyPagesSortColumn !== column) { return ''; } return window.moneyPagesSortDirection === 'asc' ? '' : ''; }; // Get date range for display const dateRange = parseInt(localStorage.getItem('gsc_date_range') || '30', 10); const dateRangeText = dateRange === 30 ? '30 days' : dateRange === 60 ? '60 days' : dateRange === 90 ? '90 days' : dateRange === 120 ? '120 days' : dateRange === 180 ? '180 days' : dateRange === 365 ? '365 days' : dateRange === 540 ? '540 days' : `${dateRange} days`; // Get current filter values (stored globally) - normalize for robustness const currentCategoryFilter = String(window.moneyPagesCategoryFilter || 'ALL').toUpperCase(); const currentSubSegmentFilter = String(window.moneyPagesSubSegmentFilter || 'ALL').toUpperCase(); const currentMinImpressions = Number(window.moneyPagesMinImpressions || 0) || 0; // Get zero impressions filter (default to true - include zero impressions) const includeZero = window.moneyPagesIncludeZero !== false; // Apply filters before sorting/pagination let filteredRows = sortedRows.filter(row => { const rowCat = String(row.category || '').toUpperCase(); const rowSub = String(row.subSegment || row.segmentType || '').toUpperCase(); const matchCat = currentCategoryFilter === 'ALL' || rowCat === currentCategoryFilter; const matchSubSeg = currentSubSegmentFilter === 'ALL' || rowSub === currentSubSegmentFilter; const matchImp = (row.impressions || 0) >= currentMinImpressions; const matchZero = includeZero || (row.impressions || 0) > 0; // Include zero impressions if checkbox is checked return matchCat && matchSubSeg && matchImp && matchZero; }); // Expose current filtered Opportunity rows so Suggested Top 10 can align with the table. window.moneyPagesOpportunityFilteredRows = filteredRows; // Update pagination based on filtered rows const filteredTotalPages = Math.ceil(filteredRows.length / rowsPerPage); const filteredStartIdx = (currentPage - 1) * rowsPerPage; const filteredEndIdx = filteredStartIdx + rowsPerPage; const pageRows = filteredRows.slice(filteredStartIdx, filteredEndIdx); let tableHtml = `

Money Pages Opportunity Table

Filter by opportunity type and impressions to focus optimisation work.

Data period: Last ${dateRangeText} • Showing ${filteredStartIdx + 1}-${Math.min(filteredEndIdx, filteredRows.length)} of ${filteredRows.length}
`; // ============================================ // STEP 3: Fetch optimisation statuses from database (ALWAYS fetch, don't rely on cache) // ============================================ // Store row data by normalized URL for Track button access // Always initialize the map if it doesn't exist if (!window.moneyPagesRowDataByUrl) { window.moneyPagesRowDataByUrl = new Map(); } // CRITICAL: DO NOT fetch statuses here - the caller should fetch before calling renderMoneyPagesTable // Fetching here causes the cache to be cleared every time the table renders, losing status information // Instead, we rely on the cache that was populated by the caller debugLog(`[Money Pages Opportunity] Rendering table using existing cache: cacheSize=${window.optimisationStatusCache?.size || 0}, rowsCount=${moneyRows.length}`, 'info'); pageRows.forEach((row, idx) => { const isEven = idx % 2 === 0; const rowBg = isEven ? '#ffffff' : '#fafafa'; const ctrPct = (row.ctr || 0) * 100; const ctrColor = ctrPct >= 2 ? '#10b981' : ctrPct >= 1 ? '#f59e0b' : '#ef4444'; const posColor = row.avgPosition <= 5 ? '#10b981' : row.avgPosition <= 10 ? '#f59e0b' : '#ef4444'; const categoryColor = row.categoryColor === 'green' ? '#10b981' : row.categoryColor === 'amber' ? '#f59e0b' : '#ef4444'; // Extract title from row data or generate from URL const pageTitle = row.title || (() => { try { const urlObj = new URL(row.url); const pathParts = urlObj.pathname.split('/').filter(p => p); if (pathParts.length > 0) { // Convert last path segment to title case const lastPart = pathParts[pathParts.length - 1]; return lastPart.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1) ).join(' '); } } catch (e) {} return null; // Will show URL if no title })(); // Meta description from row data const metaDescription = row.metaDescription || null; // Store row data by normalized URL for Track button access // Ensure we store a complete copy with all metrics const normalizedRowUrl = normalizeUrlForDedupe ? normalizeUrlForDedupe(row.url) : row.url.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, ''); const rowDataToStore = { url: row.url, title: row.title, clicks: row.clicks || 0, impressions: row.impressions || 0, ctr: row.ctr || 0, avgPosition: row.avgPosition != null ? row.avgPosition : (row.position || null), position: row.position || row.avgPosition || null, segment: row.segment || 'money_pages', metaDescription: row.metaDescription || null, category: row.category, categoryLabel: row.categoryLabel, categoryColor: row.categoryColor, recommendation: row.recommendation, schemaTypes: row.schemaTypes || [] }; window.moneyPagesRowDataByUrl.set(normalizedRowUrl, rowDataToStore); // Debug: Log if this is the landscape page if (row.url && row.url.includes('landscape-photography-workshops')) { console.log('[Money Pages] Stored row data for landscape page:', rowDataToStore); } // Safe strings for inline handlers const safeUrl = String(row.url || '').replace(/'/g, "\\'").replace(/\"/g, '"'); const safeTitle = String(pageTitle || row.url || 'Untitled').replace(/'/g, "\\'").replace(/\"/g, '"'); tableHtml += ` `; }); tableHtml += `
# URL / Title / Meta Details
Page
type
${getSortIcon('type')}
Clicks${getSortIcon('clicks')} Impressions${getSortIcon('impressions')} CTR${getSortIcon('ctr')}
Click
potential
${getSortIcon('clickPotential')}
Avg
Position
${getSortIcon('position')}
AI citations
${getSortIcon('aiCitations')}
Opportunity${getSortIcon('opportunity')}
Schema
Types
Recommended action Track / Manage
${filteredStartIdx + idx + 1}
${row.url}
${pageTitle ? `
${pageTitle}
` : ''} ${metaDescription ? `
${metaDescription}
` : '
No meta description available
'}
${(() => { const sub = String(row.subSegment || row.segmentType || '').toLowerCase(); const label = sub === 'product' ? 'product' : sub === 'event' ? 'event' : sub === 'landing' ? 'landing' : (sub || '—'); const bg = label === 'landing' ? '#dbeafe' : label === 'event' ? '#fee2e2' : label === 'product' ? '#dcfce7' : '#e5e7eb'; const color = label === 'landing' ? '#1d4ed8' : label === 'event' ? '#991b1b' : label === 'product' ? '#166534' : '#374151'; return `${label}`; })()} ${(row.clicks || 0).toLocaleString()} ${(row.impressions || 0).toLocaleString()} ${ctrPct.toFixed(1)}% ${(() => { const impr = Number(row.impressions || 0) || 0; const clk = Number(row.clicks || 0) || 0; const potential = Math.max(0, Math.round((impr * 0.025) - clk)); return potential.toLocaleString('en-GB'); })()} ${row.avgPosition ? row.avgPosition.toFixed(1) : '—'} ${(row.aiCitations || 0).toLocaleString()} ${(() => { // Split categoryLabel into main part and bracketed part const label = row.categoryLabel || ''; const bracketMatch = label.match(/^(.+?)\s*\((.+?)\)$/); if (bracketMatch) { const mainPart = bracketMatch[1].trim(); const bracketPart = bracketMatch[2].trim(); return `
${mainPart}
(${bracketPart})
`; } // If no brackets, just show the label return ` ${label} `; })()}
${(() => { const schemaTypes = row.schemaTypes || []; if (schemaTypes.length === 0) { return '0'; } // Ensure schemaTypes are strings (handle both string arrays and object arrays) const typeStrings = schemaTypes.map(t => { if (typeof t === 'string') return t; if (typeof t === 'object' && t !== null) { // Handle objects like {type: 'FAQPage', count: 5} return t.type || String(t); } return String(t); }).filter(Boolean); if (typeStrings.length === 0) { return '0'; } // Show just the count with hover tooltip showing full list const count = typeStrings.length; const fullList = typeStrings.join(', '); const countText = count === 1 ? '1 schema type' : `${count} schema types`; return `${countText}`; })()}
${row.recommendation}
${(() => { // Use the same status lookup as Ranking & AI for consistency const taskType = 'on_page'; // Create a row-like object for getOptimisationStatus (it expects keyword and best_url/targetUrl) const statusRow = { keyword: '', // Empty for page-level tasks best_url: row.url, targetUrl: row.url, ranking_url: row.url }; // First check the main cache let status = window.getOptimisationStatus ? window.getOptimisationStatus(statusRow, taskType) : null; // Debug: Log status lookup for ALL pages (not just landscape) to trace the issue if (status) { console.log('[Money Pages] Status lookup result:', { url: row.url, hasId: 'id' in status, hasTaskId: 'task_id' in status, id: status.id, task_id: status.task_id, status: status.status, allKeys: Object.keys(status).slice(0, 10) // First 10 keys }); // CRITICAL: Ensure id field exists - if not, try to get it from task_id or log error if (!status.id) { console.error('[Money Pages] Status object missing id field:', { url: row.url, status, hasTaskId: 'task_id' in status, task_id: status.task_id }); // Try to use task_id as fallback if (status.task_id) { status.id = status.task_id; console.warn('[Money Pages] Using task_id as fallback for id'); } } } else { // Log when status is not found const urlKey = window.cleanUrlForKey ? window.cleanUrlForKey(row.url) : row.url.toLowerCase().trim(); const expectedKey = `::${urlKey}::on_page`; const cacheHasKey = window.optimisationStatusCache ? window.optimisationStatusCache.has(expectedKey) : false; console.log('[Money Pages] Status not found for:', { url: row.url, urlKey, expectedKey, cacheHasKey, cacheSize: window.optimisationStatusCache?.size || 0 }); } // If not found, check temporary cache (for newly created tasks) if (!status && window.moneyPagesTaskCache) { const urlKey = window.cleanUrlForKey ? window.cleanUrlForKey(row.url) : row.url.toLowerCase().trim(); const tempTask = window.moneyPagesTaskCache.get(urlKey); if (tempTask) { // Build a status object from the temporary cache status = { id: tempTask.id, status: tempTask.status || 'planned', cycle_number: tempTask.cycle_number || 1, cycle_id: tempTask.cycle_id || null, objective_state: 'on_track' }; console.log('[Money Pages] Using temporary cache for:', row.url, status); } } // Debug logging for landscape page if (row.url && row.url.includes('landscape-photography-workshops')) { const urlKey = window.cleanUrlForKey ? window.cleanUrlForKey(row.url) : row.url.toLowerCase().trim(); const expectedKey = `::${urlKey}::on_page`; const cacheHasKey = window.optimisationStatusCache ? window.optimisationStatusCache.has(expectedKey) : false; const allKeys = window.optimisationStatusCache ? Array.from(window.optimisationStatusCache.keys()) : []; const matchingKeys = allKeys.filter(k => k.includes(urlKey) || k.includes('landscape')); console.log('[Money Pages] Status lookup for landscape page:', { url: row.url, urlKey, expectedKey, found: !!status, status: status?.status, taskId: status?.id, cacheSize: window.optimisationStatusCache ? window.optimisationStatusCache.size : 0, tempCacheSize: window.moneyPagesTaskCache ? window.moneyPagesTaskCache.size : 0, cacheHasKey, matchingKeys: matchingKeys.slice(0, 5) }); } if (!status || typeof status !== 'object' || !status.status) { // Not tracked - show Track button return `
Not tracked
`; } // Tracked - show status pill + cycle info + Manage button (same as Ranking & AI) const statusText = { 'planned': 'Planned', 'in_progress': 'In progress', 'monitoring': 'Monitoring', 'done': 'Done', 'paused': 'Paused', 'cancelled': 'Cancelled' }[status.status] || status.status; // Status colors (matching Ranking & AI) let statusBg, statusColor; if (status.status === 'planned') { statusBg = '#e9d5ff'; statusColor = '#6b21a8'; } else if (status.status === 'in_progress') { statusBg = '#fed7aa'; statusColor = '#9a3412'; } else if (status.status === 'monitoring') { statusBg = '#a7f3d0'; statusColor = '#065f46'; } else if (status.status === 'done') { statusBg = '#dcfce7'; statusColor = '#166534'; } else if (status.status === 'paused') { statusBg = '#e5e7eb'; statusColor = '#374151'; } else if (status.status === 'cancelled') { statusBg = '#fee2e2'; statusColor = '#991b1b'; } else { statusBg = '#f9fafb'; statusColor = '#4b5563'; } // Cycle info const cycleNo = status.cycle_active || 1; let cycleText = 'Cycle ' + cycleNo; if (status.last_activity_at) { const lastActivity = new Date(status.last_activity_at); const now = new Date(); const daysAgo = Math.floor((now - lastActivity) / (1000 * 60 * 60 * 24)); const timeText = daysAgo === 0 ? 'Today' : daysAgo === 1 ? '1 day ago' : daysAgo + ' days ago'; cycleText += ' ' + timeText; } const taskId = status.id || status.task_id; // Validate taskId exists if (!taskId) { console.error('[Money Pages] Task ID missing in status object:', { status, hasId: 'id' in status, hasTaskId: 'task_id' in status, statusKeys: Object.keys(status), url: row.url }); return '
Error: Task ID missing
'; } // Debug: Log taskId extraction for landscape page if (row.url && row.url.includes('landscape-photography-workshops')) { console.log('[Money Pages] TaskId extraction for landscape page:', { url: row.url, statusId: status.id, statusTaskId: status.task_id, extractedTaskId: taskId, statusType: typeof taskId, statusObject: { id: status.id, task_id: status.task_id, hasId: 'id' in status, hasTaskId: 'task_id' in status, allKeys: Object.keys(status).slice(0, 15) } }); } // Escape taskId for use in onclick attribute (it's a UUID string, so needs quotes) const taskIdEscaped = String(taskId).replace(/'/g, "\\'").replace(/"/g, '"'); // DEBUG: Log the onclick handler that will be generated if (row.url && row.url.includes('landscape-photography-workshops')) { const onclickHandler = `window.openOptimisationTaskDrawer('${taskIdEscaped}')`; console.log('[Money Pages] Generated onclick handler for landscape page:', { taskId: taskId, taskIdEscaped: taskIdEscaped, onclickHandler: onclickHandler, onclickLength: onclickHandler.length }); } return '
' + '' + escapeHtml(statusText) + '' + '
' + escapeHtml(cycleText) + '
' + '' + '
'; })()}
`; // Pagination controls and total count (using filtered data) tableHtml += `
${filteredTotalPages > 1 ? `Page ${currentPage} of ${filteredTotalPages} • ` : ''}Total URLs: ${filteredRows.length}${filteredRows.length !== sortedRows.length ? ` (filtered from ${sortedRows.length})` : ''}
${filteredTotalPages > 1 ? ` ` : ''}
`; return tableHtml; } catch (error) { debugLog(`Error in renderMoneyPagesTable: ${error.message}`, 'error'); return '
Error rendering table. Please refresh the page.
'; } } // Get filtered money pages metrics based on current filter state // (canonical implementation lives later in the file; this old duplicate was removed) // Update summary metrics with sub-segment filtering function updateMoneyPagesSummaryMetrics(moneyPagesMetrics) { if (!moneyPagesMetrics) return; // Use the new function that applies all filters const filteredMetrics = getFilteredMoneyPagesMetrics(moneyPagesMetrics); if (!filteredMetrics) return; const { overview } = filteredMetrics; const summaryClickEl = document.getElementById('money-summary-click-share'); const summaryCtrEl = document.getElementById('money-summary-ctr'); const summaryPosEl = document.getElementById('money-summary-position'); const summaryCovEl = document.getElementById('money-summary-coverage'); if (!overview || !summaryClickEl || !summaryCtrEl || !summaryPosEl || !summaryCovEl) { return; } const moneyClicks = overview.moneyClicks || 0; const moneyImpressions = overview.moneyImpressions || 0; const siteClicks = overview.siteTotalClicks || 0; const siteImpressions = overview.siteTotalImpressions || 0; // Summary metrics const clickSharePct = siteClicks && siteClicks > 0 ? (moneyClicks / siteClicks) * 100 : null; const moneyCtrPct = (overview.moneyCtr || 0) * 100; const siteCtrPct = (overview.siteCtr || 0) * 100; const moneyPos = overview.moneyAvgPosition; const sitePos = overview.siteAvgPosition; const coverageCount = overview.moneyCoverageCount || 0; // Helper for RAG label class function labelClass(score) { if (score == null) return 'pill pill-muted'; if (score >= 70) return 'pill pill-success'; if (score >= 40) return 'pill pill-warning'; return 'pill pill-danger'; } // Update cards summaryClickEl.innerHTML = `
Share of site clicks
${clickSharePct != null ? clickSharePct.toFixed(1) + '%' : '—'}
Money pages clicks: ${moneyClicks.toLocaleString()} ${siteClicks ? ` of ${siteClicks.toLocaleString()} total` : ''}
`; let ctrScore = null; if (siteCtrPct > 0) { ctrScore = moneyCtrPct >= siteCtrPct ? 75 : 30; } summaryCtrEl.innerHTML = `
CTR vs site
${moneyCtrPct.toFixed(2)}% vs ${siteCtrPct.toFixed(1)}% site
`; summaryCtrEl.className = 'metric-card ' + labelClass(ctrScore); let posScore = null; if (moneyPos && sitePos) { posScore = moneyPos <= sitePos ? 70 : 30; } summaryPosEl.innerHTML = `
Average position
${moneyPos ? moneyPos.toFixed(1) : '—'} vs ${sitePos ? sitePos.toFixed(1) : '—'} site
`; summaryPosEl.className = 'metric-card ' + labelClass(posScore); summaryCovEl.innerHTML = `
Money pages with impressions
${coverageCount}
Based on GSC pages only
`; } // Update sub-segment dropdown counts function updateMoneyPagesSubSegmentCounts(moneyPagesMetrics) { if (!moneyPagesMetrics || !moneyPagesMetrics.rows) return; const rows = moneyPagesMetrics.rows || []; // Count by sub-segment const counts = { ALL: rows.length, PRODUCT: 0, EVENT: 0, LANDING: 0 }; rows.forEach(row => { const subSegment = row.subSegment || row.segmentType || 'LANDING'; if (subSegment === 'PRODUCT' || subSegment === 'product') { counts.PRODUCT++; } else if (subSegment === 'EVENT' || subSegment === 'event') { counts.EVENT++; } else if (subSegment === 'LANDING' || subSegment === 'landing') { counts.LANDING++; } }); // Update dropdown options with counts const allOption = document.getElementById('money-subsegment-all'); const productOption = document.getElementById('money-subsegment-product'); const eventOption = document.getElementById('money-subsegment-event'); const landingOption = document.getElementById('money-subsegment-landing'); if (allOption) allOption.textContent = `All sub-segments (${counts.ALL})`; if (productOption) productOption.textContent = `Product Pages (${counts.PRODUCT})`; if (eventOption) eventOption.textContent = `Event Pages (${counts.EVENT})`; if (landingOption) landingOption.textContent = `Landing Pages (${counts.LANDING})`; } // Update type filter dropdown counts (for Priority & Actions) function updateMoneyPagesTypeFilterCounts(sourcePages) { if (!sourcePages || !Array.isArray(sourcePages)) return; // Count by segment type const counts = { all: sourcePages.length, authority: 0, landing: 0, event: 0, product: 0 }; sourcePages.forEach(page => { const segmentType = page.segmentType || 'landing'; if (segmentType === 'authority') { counts.authority++; } else if (segmentType === 'landing') { counts.landing++; } else if (segmentType === 'event') { counts.event++; } else if (segmentType === 'product') { counts.product++; } }); // Update dropdown options with counts const typeFilterEl = document.getElementById('money-pages-type-filter'); if (typeFilterEl) { const options = typeFilterEl.querySelectorAll('option'); options.forEach(opt => { const value = opt.value; if (value === 'all') { opt.textContent = `All money pages (${counts.all})`; } else if (value === 'authority') { opt.textContent = `Authority (${counts.authority})`; } else if (value === 'landing') { opt.textContent = `Landing pages (${counts.landing})`; } else if (value === 'event') { opt.textContent = `Event pages (${counts.event})`; } else if (value === 'product') { opt.textContent = `Product pages (${counts.product})`; } }); } } // Function to populate AI citations for Money Pages table async function populateMoneyPagesAiCitations(rows) { try { // If no rows provided, try to get from window.moneyPagesData if (!rows || rows.length === 0) { rows = window.moneyPagesData || []; } if (!rows || rows.length === 0) { return; } // Wait a bit for DOM to be ready await new Promise(resolve => setTimeout(resolve, 300)); // Check if table container exists let tableContainer = document.getElementById('money-pages-table-container'); if (!tableContainer) { return; } // Verify tableContainer is a valid DOM element if (typeof tableContainer.querySelectorAll !== 'function') { console.error('[Money Pages AI Citations] tableContainer is not a valid DOM element'); return; } // Check if cells exist in the table const cells = tableContainer.querySelectorAll('.ai-citation-cell-opportunity'); if (!cells || cells.length === 0) { console.warn('[Money Pages AI Citations] No cells found in table'); return; } console.log(`[Money Pages AI Citations] Found ${cells.length} cells to populate`); // Get property URL for API calls const propertyUrl = document.getElementById('propertyUrl')?.value || localStorage.getItem('property_url') || ''; if (!propertyUrl) { return; } // Normalize URL helper function (same as API endpoint) const normalizeUrl = (url) => { if (!url) return ''; let normalized = String(url).toLowerCase().trim(); normalized = normalized.replace(/^https?:\/\//, ''); normalized = normalized.replace(/^www\./, ''); normalized = normalized.split('?')[0].split('#')[0]; // Extract path portion const parts = normalized.split('/'); if (parts.length > 1) { normalized = parts.slice(1).join('/'); } normalized = normalized.replace(/^\/+/, '').replace(/\/+$/, ''); return normalized; }; // Try to get citation counts from localStorage cache first let citationCache = {}; // Store cache globally for sorting to access if (!window.moneyPagesCitationCache) { window.moneyPagesCitationCache = {}; } try { const lastAuditResults = localStorage.getItem('last_audit_results'); if (lastAuditResults) { const auditData = JSON.parse(lastAuditResults); // Try multiple paths to find ranking_ai_data let combinedRows = []; if (auditData?.ranking_ai_data?.combinedRows) { combinedRows = auditData.ranking_ai_data.combinedRows; } else if (auditData?.searchData?.ranking_ai_data?.combinedRows) { combinedRows = auditData.searchData.ranking_ai_data.combinedRows; } else if (Array.isArray(auditData?.ranking_ai_data)) { combinedRows = auditData.ranking_ai_data; } else if (Array.isArray(auditData?.searchData?.ranking_ai_data)) { combinedRows = auditData.searchData.ranking_ai_data; } if (Array.isArray(combinedRows) && combinedRows.length > 0) { // Build cache: for each URL, count how many keywords cite it rows.forEach((row) => { const targetUrl = row.url || ''; if (!targetUrl) return; const targetNormalized = normalizeUrl(targetUrl); let citationCount = 0; combinedRows.forEach(keywordRow => { const citations = keywordRow.ai_alan_citations || []; if (Array.isArray(citations) && citations.length > 0) { citations.forEach(citation => { let citedUrl = ''; if (typeof citation === 'string') { citedUrl = citation; } else if (citation && typeof citation === 'object') { citedUrl = citation.url || citation.URL || citation.link || citation.href || citation.page || citation.pageUrl || citation.target || citation.targetUrl || citation.best_url || citation.bestUrl || ''; } if (citedUrl) { const citedNormalized = normalizeUrl(citedUrl); // Try exact match first, then flexible matching if (citedNormalized === targetNormalized) { citationCount++; } else if (citedNormalized.includes(targetNormalized) || targetNormalized.includes(citedNormalized)) { citationCount++; } else { // Also try matching by last path segment const targetPath = targetNormalized.split('/').filter(p => p).pop() || ''; const citedPath = citedNormalized.split('/').filter(p => p).pop() || ''; if (targetPath && citedPath && targetPath === citedPath && targetPath.length > 5) { citationCount++; } } } }); } }); // Always cache the result (even if 0) for this URL citationCache[targetUrl] = citationCount; window.moneyPagesCitationCache[targetUrl] = citationCount; // Also set on row object for immediate sorting row._aiCitations = citationCount; }); } } } catch (err) { console.error('[Money Pages AI Citations] Error reading localStorage:', err); } // Update cells with cached data first // Build a normalized URL lookup map for efficient matching const normalizedCache = {}; Object.keys(citationCache).forEach(url => { const normalized = normalizeUrl(url); normalizedCache[normalized] = citationCache[url]; }); cells.forEach((cell) => { const pageUrl = cell.getAttribute('data-page-url'); if (!pageUrl) return; // Try exact match first, then normalized match let cachedCount = citationCache[pageUrl]; if (typeof cachedCount !== 'number') { const pageUrlNormalized = normalizeUrl(pageUrl); cachedCount = normalizedCache[pageUrlNormalized]; } if (typeof cachedCount === 'number') { cell.textContent = cachedCount.toLocaleString(); cell.style.color = '#0f172a'; cell.style.fontWeight = '600'; // Also store in row data for sorting const pageUrlNormalized = normalizeUrl(pageUrl); const row = rows.find(r => { const rowUrl = r.url || ''; return rowUrl === pageUrl || normalizeUrl(rowUrl) === pageUrlNormalized; }); if (row) { row._aiCitations = cachedCount; } } else { // If not in cache or count is 0, show loading indicator (will be updated by API) cell.textContent = '⏳'; cell.style.color = '#94a3b8'; cell.style.fontWeight = '500'; } }); // CRITICAL FIX: Always fetch from API to verify cache accuracy // The localStorage cache might have incorrect counts due to URL matching issues // Fetch for ALL URLs, but prioritize those with 0 or undefined counts const urlsToFetch = rows .map(row => row.url) .filter(url => url && url.trim().length > 0); // Sort: URLs with 0 or undefined counts first, then others urlsToFetch.sort((a, b) => { const aCount = citationCache[a] || 0; const bCount = citationCache[b] || 0; if (aCount === 0 && bCount > 0) return -1; if (aCount > 0 && bCount === 0) return 1; return 0; }); if (urlsToFetch.length > 0) { // Build cell map ONCE before processing batches (more efficient) const currentTableContainer = document.getElementById('money-pages-table-container'); if (!currentTableContainer || typeof currentTableContainer.querySelectorAll !== 'function') { console.warn('[Money Pages AI Citations] Table container not found when building cell map'); return; } const allCells = currentTableContainer.querySelectorAll('.ai-citation-cell-opportunity'); // Create a map: URL (exact and normalized) -> cell element const cellMap = new Map(); Array.from(allCells).forEach(cell => { const cellUrl = cell.getAttribute('data-page-url'); if (cellUrl) { // Store exact URL cellMap.set(cellUrl, cell); // Also store normalized version for flexible matching const cellUrlNormalized = normalizeUrl(cellUrl); if (cellUrlNormalized !== cellUrl && !cellMap.has(cellUrlNormalized)) { cellMap.set(cellUrlNormalized, cell); } } }); console.log(`[Money Pages AI Citations] Built cell map with ${cellMap.size} entries from ${allCells.length} cells`); // Debug: Log sample cell URLs to understand format if (allCells.length > 0) { const sampleCellUrls = Array.from(allCells).slice(0, 5).map(c => c.getAttribute('data-page-url')); console.log(`[Money Pages AI Citations] Sample cell URLs (first 5):`, sampleCellUrls); } // Debug: Log sample row URLs we'll be searching for if (rows.length > 0) { const sampleRowUrls = rows.slice(0, 5).map(r => r.url); console.log(`[Money Pages AI Citations] Sample row URLs to search (first 5):`, sampleRowUrls); } // Fetch citations for all URLs in parallel (limit to 10 at a time) const batchSize = 10; let cellNotFoundCount = 0; for (let i = 0; i < urlsToFetch.length; i += batchSize) { const batch = urlsToFetch.slice(i, i + batchSize); const promises = batch.map(async (url) => { try { const apiUrlFull = apiUrl(`/api/supabase/query-keywords-citing-url?property_url=${encodeURIComponent(propertyUrl)}&target_url=${encodeURIComponent(url)}`); const response = await fetch(apiUrlFull); if (response.ok) { const result = await response.json(); if (result.status === 'ok' && typeof result.count === 'number') { return { url, count: result.count }; } } } catch (err) { // Silently handle fetch errors } return { url, count: 0 }; }); const results = await Promise.all(promises); results.forEach(({ url, count }) => { citationCache[url] = count; // Also store in global cache for sorting window.moneyPagesCitationCache[url] = count; // CRITICAL: Find the matching cell using multiple strategies const urlNormalized = normalizeUrl(url); let cell = null; // Strategy 1: Try exact match in cellMap cell = cellMap.get(url) || cellMap.get(urlNormalized); // Strategy 2: If not found, iterate through all cells with flexible matching if (!cell) { cell = Array.from(allCells).find(c => { const cellUrl = c.getAttribute('data-page-url'); if (!cellUrl) return false; // Exact match if (cellUrl === url) return true; // Normalized match const cellUrlNormalized = normalizeUrl(cellUrl); if (cellUrlNormalized === urlNormalized) return true; // Bidirectional partial match if (cellUrlNormalized && urlNormalized) { if (cellUrlNormalized.includes(urlNormalized) || urlNormalized.includes(cellUrlNormalized)) { return true; } } // Match by last path segment const urlPath = urlNormalized.split('/').filter(p => p).pop() || ''; const cellPath = cellUrlNormalized.split('/').filter(p => p).pop() || ''; if (urlPath && cellPath && urlPath === cellPath && urlPath.length > 5) { return true; } return false; }); } // Strategy 3: If still not found, try matching by finding the row first, then its cell if (!cell) { const matchingRow = rows.find(r => { const rowUrl = r.url || ''; if (rowUrl === url) return true; const rowUrlNormalized = normalizeUrl(rowUrl); return rowUrlNormalized === urlNormalized || rowUrlNormalized.includes(urlNormalized) || urlNormalized.includes(rowUrlNormalized); }); if (matchingRow) { // Find cell by matching the row's URL to cell's data-page-url cell = Array.from(allCells).find(c => { const cellUrl = c.getAttribute('data-page-url'); if (!cellUrl) return false; const rowUrl = matchingRow.url || ''; if (cellUrl === rowUrl) return true; const cellUrlNormalized = normalizeUrl(cellUrl); const rowUrlNormalized = normalizeUrl(rowUrl); return cellUrlNormalized === rowUrlNormalized || cellUrlNormalized.includes(rowUrlNormalized) || rowUrlNormalized.includes(cellUrlNormalized); }); } } if (cell) { cell.textContent = count.toLocaleString(); cell.style.color = '#0f172a'; cell.style.fontWeight = '600'; // Update row data for sorting const row = rows.find(r => { const rowUrl = r.url || ''; if (rowUrl === url) return true; const rowUrlNormalized = normalizeUrl(rowUrl); return rowUrlNormalized === urlNormalized || rowUrlNormalized.includes(urlNormalized) || urlNormalized.includes(rowUrlNormalized); }); if (row) { row._aiCitations = count; } } else { cellNotFoundCount++; // Log for debugging but don't spam console if (cellNotFoundCount <= 3) { console.log(`[Money Pages AI Citations] Cell not found for URL: ${url} (count: ${count})`); } } }); } // Suppress console warnings - URL matching issues are expected and not actionable } } catch (err) { console.error('[Money Pages AI Citations] CRITICAL ERROR in function:', err); console.error('[Money Pages AI Citations] Error stack:', err.stack); debugLog(`[Money Pages] Critical error in populateMoneyPagesAiCitations: ${err.message}`, 'error'); } } // Debug function: manually test AI citations population window.testMoneyPagesAiCitations = function() { const tableContainer = document.getElementById('money-pages-table-container'); if (!tableContainer) { console.error('Table container not found'); return; } const cells = tableContainer.querySelectorAll('.ai-citation-cell-opportunity'); console.log('Found', cells.length, 'cells'); cells.forEach((cell, idx) => { const url = cell.getAttribute('data-page-url'); console.log(`Cell ${idx}:`, url, 'Current text:', cell.textContent); }); // Get rows from window.moneyPagesData const rows = window.moneyPagesData || []; console.log('Available rows:', rows.length); if (rows.length > 0) { console.log('First 3 row URLs:', rows.slice(0, 3).map(r => r.url)); populateMoneyPagesAiCitations(rows); } else { console.error('No rows data available'); } }; // Function to render Money Pages Performance section async function renderMoneyPagesSection(moneyPagesMetrics) { // CRITICAL: Fetch optimisation statuses BEFORE rendering tables to ensure cache is populated. // Only do this when an admin key is present; otherwise the endpoint returns 401 and clears cache. const canFetchOptimisationStatuses = typeof window.fetchOptimisationStatuses === 'function' && typeof window.hasAdminKey === 'function' && window.hasAdminKey(); if (canFetchOptimisationStatuses) { const allStatusRows = []; // Add Opportunity table rows if (moneyPagesMetrics && moneyPagesMetrics.rows && moneyPagesMetrics.rows.length > 0) { moneyPagesMetrics.rows.forEach(row => { allStatusRows.push({ keyword: '', // Empty for page-level tasks best_url: row.url, targetUrl: row.url, ranking_url: row.url }); }); } // Add Priority table rows if (window.moneyPagePriorityData && window.moneyPagePriorityData.length > 0) { window.moneyPagePriorityData.forEach(p => { allStatusRows.push({ keyword: '', // Empty for page-level tasks best_url: p.url, targetUrl: p.url, ranking_url: p.url }); }); } if (allStatusRows.length > 0) { debugLog(`[Money Pages] Fetching statuses for ${allStatusRows.length} rows before initial render`, 'info'); try { await window.fetchOptimisationStatuses(allStatusRows); debugLog(`[Money Pages] Status cache populated: ${window.optimisationStatusCache?.size || 0} entries`, 'success'); } catch (err) { debugLog(`[Money Pages] Error fetching statuses before render: ${err.message}`, 'error'); } } } else { if (moneyPagesMetrics?.rows?.length) { debugLog('[Money Pages] Skipping optimisation status fetch (admin key not set)', 'info'); } } // Get or create the Money Pages panel const moneyPanel = document.querySelector('.aigeo-panel[data-panel="money"]'); if (!moneyPanel) { debugLog('⚠ Money Pages panel not found', 'warn'); return; } // Get or create the money-pages-section element let section = document.getElementById('money-pages-section'); if (!section) { debugLog('⚠ Money Pages section element not found, creating it...', 'warn'); // Create the Money Pages HTML structure if it doesn't exist section = document.createElement('div'); section.id = 'money-pages-section'; section.style.display = 'block'; moneyPanel.appendChild(section); } // Check if full structure already exists (created by displayDashboard) const existingHeader = section.querySelector('h3[style*="color: var(--brand-dark)"]'); const existingContainer = section.querySelector('#money-pages-suggested-top10-container'); if (existingHeader && existingHeader.textContent.includes('Money Pages Performance & Actions') && existingContainer) { debugLog('✓ Money Pages full structure already exists including Suggested Top 10 container, skipping HTML recreation', 'info'); // Don't overwrite - just ensure section is visible section.style.display = 'block'; } else { // Always update the HTML structure to ensure it has the latest canvas IDs // Create the full Money Pages HTML structure with all sections section.innerHTML = `

Money Pages Performance & Actions

Focused view of your money pages: how much traffic they capture, how they convert clicks, and which URLs to improve first. This section uses 30-day Google Search Console data and historical audits to show how your commercial pages perform over time and how much they contribute to your overall Authority behaviour score.

Performance Metrics

Note: These metrics include all money pages regardless of ranking position (positions 1-20 and beyond). This provides a complete view of your money page performance, including pages that need improvement.

Difference from Segment Overview: The "Segment overview (CTR & ranking)" table above shows metrics for money pages ranking in positions 1-20 only (used for Authority scoring). The Money Pages Performance section shows all money pages to help identify opportunities across your entire money page portfolio.

Money Pages Behaviour

Money pages behaviour
CTR and ranking for money pages only.
CTR (ranking queries)
Target 2.5%
Top-10 CTR
Target 4.0%

Behaviour score here is calculated the same way as Authority → Behaviour, but only for money pages. Improvements you make on these URLs will move both this score and the Authority pillar.

Money Pages Opportunity Mix

How your money pages are distributed across improvement categories in this audit window.

Filtered Summary

Pages: -
Impressions: -
Clicks: -
CTR: -
Behaviour Score: -

Money Pages Performance Trends (last 28 days)

Weekly trends calculated from actual Google Search Console data for the last 28 days. Shows money pages clicks, impressions, CTR, and behaviour score over time.

Volume Metrics

Rate & Score Metrics

Money Pages KPI Tracker (last 28 days)

Weekly KPI trends by money-page segment (All, Landing, Event, Product) calculated from actual Google Search Console data for the last 28 days.

Money Pages – Priority & Actions

Landing, event and product pages that drive revenue. Triaged by impact, difficulty and priority.

Keyword or Page Type Priority Impact Difficulty CTR Impr. Avg pos. Action

Suggested (Top 10)

Top priority pages ranked by impact and difficulty. Click 'Create Task' to start optimization tracking.

Based on the current audit date range and money-page segment only. Categories and actions are data-driven and will update each time you run a new audit.

`; } debugLog('✓ Money Pages section HTML structure created/updated', 'success'); // Add Create Tasks button to Priority & Actions header if not already present const priorityCard = document.getElementById('money-pages-priority-card'); if (priorityCard) { const cardHeader = priorityCard.querySelector('.card-header'); if (cardHeader && !document.getElementById('money-pages-create-tasks-btn')) { // Check if header already has flex layout if (!cardHeader.style.display || cardHeader.style.display !== 'flex') { cardHeader.style.display = 'flex'; cardHeader.style.justifyContent = 'space-between'; cardHeader.style.alignItems = 'flex-start'; } // Wrap existing content in a div const existingContent = Array.from(cardHeader.childNodes); const contentWrapper = document.createElement('div'); contentWrapper.style.flex = '1'; existingContent.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) { contentWrapper.appendChild(node.cloneNode(true)); } }); cardHeader.innerHTML = ''; cardHeader.appendChild(contentWrapper); // Add bulk create button (secondary/advanced) const createBtn = document.createElement('button'); createBtn.id = 'money-pages-create-tasks-btn'; createBtn.type = 'button'; createBtn.className = 'btn btn-secondary'; createBtn.textContent = 'Bulk create…'; createBtn.title = '⚠️ BULK OPERATION: Creates optimisation tasks for ALL money pages (CTR ≥ 2.5%). This will create hundreds of tasks. Use row-level Track buttons for individual pages instead.'; createBtn.style.cssText = 'margin-left: 1rem; padding: 0.5rem 1rem; background: #f59e0b; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; white-space: nowrap; font-size: 0.875rem;'; cardHeader.appendChild(createBtn); // Wire up button wireMoneyPagesCreateTasksButton(); } else if (document.getElementById('money-pages-create-tasks-btn')) { // Button exists, just wire it up wireMoneyPagesCreateTasksButton(); } } // Always show section, even if no data (will show "No data" message) section.style.display = 'block'; if (!moneyPagesMetrics) { debugLog('⚠ Money Pages Metrics: No data provided', 'warn'); // Show "No data" message in table and summary cards const tableContainer = document.getElementById('money-pages-table-container'); if (tableContainer) { tableContainer.innerHTML = '
No money-page data available. Run an audit with GSC data to see money pages performance.
'; } // Populate summary cards with "—" const summaryClickEl = document.getElementById('money-summary-click-share'); const summaryCtrEl = document.getElementById('money-summary-ctr'); const summaryPosEl = document.getElementById('money-summary-position'); const summaryCovEl = document.getElementById('money-summary-coverage'); if (summaryClickEl) summaryClickEl.innerHTML = `
Share of site clicks
No data
`; if (summaryCtrEl) summaryCtrEl.innerHTML = `
CTR vs site
No data
`; if (summaryPosEl) summaryPosEl.innerHTML = `
Average position
No data
`; if (summaryCovEl) summaryCovEl.innerHTML = `
Money pages with impressions
0
No data
`; return; } const { rows } = moneyPagesMetrics; const tableContainer = document.getElementById('money-pages-table-container'); if (!tableContainer) { return; } // Store data globally for sorting/pagination // Ensure we're using the most up-to-date metrics window.currentMoneyPagesMetrics = moneyPagesMetrics || window.currentMoneyPagesMetrics; // Initialize pagination state if not set if (window.moneyPagesCurrentPage === undefined) { window.moneyPagesCurrentPage = 1; } if (window.moneyPagesRowsPerPage === undefined) { window.moneyPagesRowsPerPage = 10; } // Store full rows array for filtering window.moneyPagesAllRows = rows; // Initialize filter state if not set if (window.moneyPagesCategoryFilter === undefined) { window.moneyPagesCategoryFilter = 'ALL'; } if (window.moneyPagesSubSegmentFilter === undefined) { window.moneyPagesSubSegmentFilter = 'ALL'; } // Update summary metrics with current filters updateMoneyPagesSummaryMetrics(moneyPagesMetrics); if (window.moneyPagesMinImpressions === undefined) { window.moneyPagesMinImpressions = 0; } // Update sub-segment dropdown counts updateMoneyPagesSubSegmentCounts(moneyPagesMetrics); // Function to apply filters and re-render ALL sections (table, chart, KPIs, summaries) const applyFiltersAndRender = async () => { const moneyPagesMetrics = window.currentMoneyPagesMetrics; const queryPages = window.currentQueryPages || null; if (!moneyPagesMetrics) return; // Get filtered metrics based on current filters FIRST const filteredMetrics = getFilteredMoneyPagesMetrics(moneyPagesMetrics); if (!filteredMetrics) return; const filteredRows = filteredMetrics.rows || []; // Update table with FILTERED rows const currentPage = window.moneyPagesCurrentPage || 1; const rowsPerPage = window.moneyPagesRowsPerPage || 10; const tableHtml = await renderMoneyPagesTable(filteredRows, currentPage, rowsPerPage); if (tableHtml && typeof tableHtml === 'string' && tableHtml.trim().length > 0) { tableContainer.innerHTML = tableHtml; } else { debugLog(`⚠ renderMoneyPagesTable returned invalid value: ${typeof tableHtml}, length: ${tableHtml?.length || 0}`, 'warn'); tableContainer.innerHTML = '
Error rendering table. Please refresh the page.
'; } // Re-attach all handlers after re-render // Use filtered rows for pagination/copy, but use original rows for filter handlers // (filter handlers need the full dataset to filter from) attachMoneyPagesSortHandlers(); attachMoneyPagesPaginationHandlers(filteredRows); attachMoneyPagesCopyHandler(filteredRows); attachMoneyPagesFilterHandlers(rows, applyFiltersAndRender); // Wire up Create Tasks button if it exists if (typeof wireMoneyPagesCreateTasksButton === 'function') { wireMoneyPagesCreateTasksButton(); } // Update all other sections with filtered data setTimeout(() => { // Update doughnut chart with filtered data renderMoneyPagesCategoryChart(moneyPagesMetrics, 0); // Recalculate behaviour for filtered pages debugLog(`🔄 Filter change: Recalculating behaviour for ${filteredRows.length} filtered rows`, 'info'); debugLog(`🔄 Filter change: queryPages available: ${!!queryPages}, length: ${queryPages?.length || 0}`, 'info'); const filteredBehaviour = window.computeMoneyPagesBehaviour ? window.computeMoneyPagesBehaviour(queryPages, filteredRows, true) : null; debugLog(`🔄 Filter change: filteredBehaviour calculated: ${!!filteredBehaviour}, impressions: ${filteredBehaviour?.impressions || 0}`, 'info'); // Update all sections renderMoneyPagesBehaviourKpis(filteredBehaviour, filteredMetrics, queryPages); updateMoneyPagesSummaryMetrics(moneyPagesMetrics); // Pass original metrics so function can apply filters internally updateMoneyPagesChartSummary(filteredMetrics); // Update dropdown counts after filtering (both table and top-level) updateMoneyPagesSubSegmentCounts(moneyPagesMetrics); }, 100); // Keep Suggested Top 10 aligned with Opportunity table filters if (typeof window.renderMoneyPagesSuggestedTop10 === 'function') { window.renderMoneyPagesSuggestedTop10(); } }; // Expose for header sort clicks window.moneyPagesApplyFilters = applyFiltersAndRender; // CRITICAL: Fetch optimisation statuses for ALL rows before initial render // (only if admin key set; otherwise will 401 and clear cache) if (canFetchOptimisationStatuses && rows.length > 0) { console.log('[Money Pages] Fetching optimisation statuses for all rows on page load...', rows.length); const allStatusRows = rows.map(row => ({ keyword: '', // Empty for page-level tasks best_url: row.url, targetUrl: row.url, ranking_url: row.url })); await window.fetchOptimisationStatuses(allStatusRows); console.log('[Money Pages] ✓ Status cache populated on page load:', window.optimisationStatusCache?.size || 0, 'entries'); } // Initial render const currentPage = window.moneyPagesCurrentPage || 1; const rowsPerPage = window.moneyPagesRowsPerPage || 10; const tableHtml = await renderMoneyPagesTable(rows, currentPage, rowsPerPage); if (tableHtml && typeof tableHtml === 'string' && tableHtml.trim().length > 0) { tableContainer.innerHTML = tableHtml; } else { debugLog(`⚠ renderMoneyPagesTable returned invalid value: ${typeof tableHtml}, length: ${tableHtml?.length || 0}`, 'warn'); tableContainer.innerHTML = '
Error rendering table. Please refresh the page.
'; } // Attach sort handlers attachMoneyPagesSortHandlers(); // Attach pagination handlers attachMoneyPagesPaginationHandlers(rows); // Attach copy button handler attachMoneyPagesCopyHandler(rows); // Attach filter handlers (Phase 2) attachMoneyPagesFilterHandlers(rows, applyFiltersAndRender); // Show section section.style.display = 'block'; } // Attach sort handlers for Money Pages table function attachMoneyPagesSortHandlers() { // Use event delegation so sorting keeps working across any table re-render path. ensureMoneyPagesOpportunitySortDelegation(); } // One-time delegated sort handler for the Money Pages Opportunity table headers. function ensureMoneyPagesOpportunitySortDelegation() { if (window.__moneyPagesOpportunitySortDelegationAttached) return; window.__moneyPagesOpportunitySortDelegationAttached = true; document.addEventListener('click', (e) => { const th = e?.target?.closest ? e.target.closest('th[id^="money-sort-"]') : null; if (!th || !th.id) return; const col = String(th.id).replace('money-sort-', ''); if (!['type', 'clicks', 'impressions', 'ctr', 'clickPotential', 'position', 'opportunity', 'aiCitations'].includes(col)) return; try { handleMoneyPagesSort(col); } catch (err) { console.warn('[Money Pages Opportunity] Sort handler error:', err); } }); } // Handle sorting for Money Pages table async function handleMoneyPagesSort(column) { if (window.moneyPagesSortColumn === column) { window.moneyPagesSortDirection = window.moneyPagesSortDirection === 'asc' ? 'desc' : 'asc'; } else { window.moneyPagesSortColumn = column; window.moneyPagesSortDirection = 'desc'; } // Reset to page 1 when sorting window.moneyPagesCurrentPage = 1; // Get current data from stored source const allRows = window.moneyPagesData || []; if (allRows.length > 0) { const tableContainer = document.getElementById('money-pages-table-container'); if (tableContainer) { const rowsPerPage = window.moneyPagesRowsPerPage || 10; tableContainer.innerHTML = await renderMoneyPagesTable(allRows, 1, rowsPerPage); setTimeout(() => { try { populateMoneyPagesAiCitations(allRows).catch(err => { debugLog(`[Money Pages] Error populating AI citations: ${err.message}`, 'warn'); }); } catch (err) { debugLog(`[Money Pages] Error calling populateMoneyPagesAiCitations: ${err.message}`, 'warn'); } }, 100); attachMoneyPagesSortHandlers(); attachMoneyPagesPaginationHandlers(allRows); attachMoneyPagesCopyHandler(allRows); } } } // Attach pagination handlers function attachMoneyPagesPaginationHandlers(allRows) { setTimeout(() => { const prevBtn = document.getElementById('money-pages-prev'); const nextBtn = document.getElementById('money-pages-next'); const rowsPerPageSelect = document.getElementById('money-pages-rows-per-page'); // Use stored sorted data for pagination const sortedRows = window.moneyPagesData || allRows || []; const rowsPerPage = window.moneyPagesRowsPerPage || 10; // Handle rows-per-page change if (rowsPerPageSelect) { const newSelect = rowsPerPageSelect.cloneNode(true); rowsPerPageSelect.parentNode.replaceChild(newSelect, rowsPerPageSelect); newSelect.addEventListener('change', async () => { const newRowsPerPage = parseInt(newSelect.value, 10); window.moneyPagesRowsPerPage = newRowsPerPage; window.moneyPagesCurrentPage = 1; // Reset to first page when changing rows per page const tableContainer = document.getElementById('money-pages-table-container'); if (tableContainer) { tableContainer.innerHTML = await renderMoneyPagesTable(sortedRows, 1, newRowsPerPage); setTimeout(() => { try { populateMoneyPagesAiCitations(sortedRows).catch(err => { debugLog(`[Money Pages] Error populating AI citations: ${err.message}`, 'warn'); }); } catch (err) { debugLog(`[Money Pages] Error calling populateMoneyPagesAiCitations: ${err.message}`, 'warn'); } }, 100); attachMoneyPagesSortHandlers(); attachMoneyPagesPaginationHandlers(sortedRows); attachMoneyPagesCopyHandler(sortedRows); } }); } if (prevBtn) { const newPrevBtn = prevBtn.cloneNode(true); prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn); newPrevBtn.addEventListener('click', async () => { if (window.moneyPagesCurrentPage > 1) { window.moneyPagesCurrentPage--; const tableContainer = document.getElementById('money-pages-table-container'); if (tableContainer) { const currentRowsPerPage = window.moneyPagesRowsPerPage || 10; tableContainer.innerHTML = await renderMoneyPagesTable(sortedRows, window.moneyPagesCurrentPage, currentRowsPerPage); setTimeout(() => { try { populateMoneyPagesAiCitations(sortedRows).catch(err => { debugLog(`[Money Pages] Error populating AI citations: ${err.message}`, 'warn'); }); } catch (err) { debugLog(`[Money Pages] Error calling populateMoneyPagesAiCitations: ${err.message}`, 'warn'); } }, 100); attachMoneyPagesSortHandlers(); attachMoneyPagesPaginationHandlers(sortedRows); attachMoneyPagesCopyHandler(sortedRows); } } }); } if (nextBtn) { const newNextBtn = nextBtn.cloneNode(true); nextBtn.parentNode.replaceChild(newNextBtn, nextBtn); newNextBtn.addEventListener('click', async () => { const currentRowsPerPage = window.moneyPagesRowsPerPage || 10; const totalPages = Math.ceil(sortedRows.length / currentRowsPerPage); if (window.moneyPagesCurrentPage < totalPages) { window.moneyPagesCurrentPage++; const tableContainer = document.getElementById('money-pages-table-container'); if (tableContainer) { tableContainer.innerHTML = await renderMoneyPagesTable(sortedRows, window.moneyPagesCurrentPage, currentRowsPerPage); setTimeout(() => { try { populateMoneyPagesAiCitations(sortedRows).catch(err => { debugLog(`[Money Pages] Error populating AI citations: ${err.message}`, 'warn'); }); } catch (err) { debugLog(`[Money Pages] Error calling populateMoneyPagesAiCitations: ${err.message}`, 'warn'); } }, 100); attachMoneyPagesSortHandlers(); attachMoneyPagesPaginationHandlers(sortedRows); attachMoneyPagesCopyHandler(sortedRows); } } }); } }, 50); } // Attach copy button handler function attachMoneyPagesCopyHandler(allRows) { setTimeout(() => { const copyBtn = document.getElementById('money-pages-copy-urls'); if (copyBtn) { const newCopyBtn = copyBtn.cloneNode(true); copyBtn.parentNode.replaceChild(newCopyBtn, copyBtn); newCopyBtn.addEventListener('click', async () => { const urls = (allRows || []).slice(0, 10).map((r) => r.url).join('\n'); if (urls) { try { await navigator.clipboard.writeText(urls); newCopyBtn.textContent = 'Copied!'; newCopyBtn.style.color = '#10b981'; setTimeout(() => { newCopyBtn.textContent = 'Copy URLs'; newCopyBtn.style.color = 'white'; }, 2000); } catch (err) { newCopyBtn.textContent = 'Copy failed'; newCopyBtn.style.color = '#ef4444'; setTimeout(() => { newCopyBtn.textContent = 'Copy URLs'; newCopyBtn.style.color = 'white'; }, 2000); } } }); } }, 50); } // Attach filter handlers for Money Pages table (Phase 2) function attachMoneyPagesFilterHandlers(allRows, onFilterChange) { // Keep latest dataset + callback; use delegation so filters survive table re-renders. window.__moneyPagesOpportunityAllRows = allRows || window.__moneyPagesOpportunityAllRows || []; window.__moneyPagesOpportunityOnFilterChange = onFilterChange || window.__moneyPagesOpportunityOnFilterChange || null; if (!window.__moneyPagesOpportunityFiltersDelegationAttached) { window.__moneyPagesOpportunityFiltersDelegationAttached = true; document.addEventListener('change', async (e) => { const t = e && e.target ? e.target : null; if (!t || !t.id) return; const isFilter = t.id === 'money-pages-filter-category' || t.id === 'money-pages-filter-subsegment' || t.id === 'money-pages-min-impressions' || t.id === 'money-pages-include-zero'; if (!isFilter) return; const categoryEl = document.getElementById('money-pages-filter-category'); const subSegEl = document.getElementById('money-pages-filter-subsegment'); const minImpEl = document.getElementById('money-pages-min-impressions'); const includeZeroEl = document.getElementById('money-pages-include-zero'); if (!categoryEl || !subSegEl || !minImpEl) return; const filterCat = categoryEl.value || 'ALL'; const filterSubSeg = subSegEl.value || 'ALL'; const minImp = parseInt(minImpEl.value, 10) || 0; const includeZero = includeZeroEl ? !!includeZeroEl.checked : true; window.moneyPagesCategoryFilter = filterCat; window.moneyPagesSubSegmentFilter = filterSubSeg; window.moneyPagesMinImpressions = minImp; window.moneyPagesIncludeZero = includeZero; window.moneyPagesCurrentPage = 1; // Sync top-level filter when table filter changes. if (t.id === 'money-pages-filter-subsegment') { const topLevelFilterEl = document.getElementById('money-top-level-filter-subsegment'); if (topLevelFilterEl && topLevelFilterEl.value !== filterSubSeg) { topLevelFilterEl.value = filterSubSeg; } } if (typeof window.__moneyPagesOpportunityOnFilterChange === 'function') { await window.__moneyPagesOpportunityOnFilterChange(); } if (typeof window.renderMoneyPagesSuggestedTop10 === 'function') { window.renderMoneyPagesSuggestedTop10(); } }); document.addEventListener('click', async (e) => { const t = e && e.target ? e.target : null; if (!t || t.id !== 'money-pages-copy-urls') return; const categoryEl = document.getElementById('money-pages-filter-category'); const subSegEl = document.getElementById('money-pages-filter-subsegment'); const minImpEl = document.getElementById('money-pages-min-impressions'); const includeZeroEl = document.getElementById('money-pages-include-zero'); const filterCat = categoryEl ? (categoryEl.value || 'ALL') : (window.moneyPagesCategoryFilter || 'ALL'); const filterSubSeg = subSegEl ? (subSegEl.value || 'ALL') : (window.moneyPagesSubSegmentFilter || 'ALL'); const minImp = minImpEl ? (parseInt(minImpEl.value, 10) || 0) : (window.moneyPagesMinImpressions || 0); const includeZero = includeZeroEl ? !!includeZeroEl.checked : (window.moneyPagesIncludeZero !== false); const all = Array.isArray(window.__moneyPagesOpportunityAllRows) ? window.__moneyPagesOpportunityAllRows : []; const filtered = all.filter(row => { const matchCat = filterCat === 'ALL' || row.category === filterCat; const matchSubSeg = filterSubSeg === 'ALL' || row.subSegment === filterSubSeg; const matchImp = (row.impressions || 0) >= minImp; const matchZero = includeZero || (row.impressions || 0) > 0; return matchCat && matchSubSeg && matchImp && matchZero; }); const urls = filtered.map(r => r.url).join('\n'); if (!urls) return; const btn = t; const prevText = btn.textContent; try { await navigator.clipboard.writeText(urls); btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = prevText; }, 1500); } catch (err) { btn.textContent = 'Copy failed'; setTimeout(() => { btn.textContent = prevText; }, 1500); } }); } // Wire up Create Tasks button after rendering completes setTimeout(() => { if (typeof wireMoneyPagesCreateTasksButton === 'function') { wireMoneyPagesCreateTasksButton(); } }, 100); } // Make renderMoneyPagesSection globally available window.renderMoneyPagesSection = renderMoneyPagesSection; // ======================================== // Money Pages → Create Optimisation Tasks Feature // ======================================== // Step 2: Fetch all Money Pages segment (ignores current UI filters) // Returns array of rows with: url, page_type, ctr, impressions, clicks, avg_position async function fetchMoneyPagesSegmentAll() { // Money Pages data is stored in window.moneyPagesMetrics or from localStorage/Supabase // Get the raw data source (before any filtering) let moneyPagesData = null; // Try window.moneyPagesMetrics first (if already loaded) if (window.moneyPagesMetrics && window.moneyPagesMetrics.rows) { moneyPagesData = window.moneyPagesMetrics; } else { // Try localStorage - check last_audit_results first, then fallback to aigeo_audit_data let savedAudit = localStorage.getItem('last_audit_results'); if (!savedAudit) { savedAudit = localStorage.getItem('aigeo_audit_data'); } if (savedAudit) { try { const parsed = JSON.parse(savedAudit); moneyPagesData = parsed.scores?.moneyPagesMetrics || parsed.moneyPagesMetrics || null; } catch (e) { debugLog('[Money Pages Tasks] Error parsing localStorage: ' + (e.message || e), 'error'); } } } if (!moneyPagesData || !moneyPagesData.rows || moneyPagesData.rows.length === 0) { console.warn('[Money Pages Tasks] No money pages data available'); return []; } // Return all rows (ignoring filters) return moneyPagesData.rows.map(row => ({ url: row.url || row.page_url || '', page_type: row.page_type || row.type || '', ctr: row.ctr || row.ctr_28d || null, impressions: row.impressions || row.impressions_28d || null, clicks: row.clicks || row.clicks_28d || null, avg_position: row.avg_position || row.position || null, page_title: row.page_title || row.title || row.slug || null })); } // Step 4: Fetch existing optimisation task URLs for de-dupe async function fetchExistingOptimizationTaskUrls() { try { const adminKey = window.getAdminKey ? window.getAdminKey() : (sessionStorage.getItem('arp_admin_key') || localStorage.getItem('arp_admin_key') || ''); if (!adminKey) { throw new Error('Admin key required'); } const response = await fetch('/api/optimisation/tasks', { headers: { 'x-arp-admin-key': adminKey } }); if (!response.ok) { throw new Error(`Failed to fetch tasks: ${response.statusText}`); } const data = await response.json(); const tasks = data.tasks || []; // Normalize URLs and return as Set const urlSet = new Set(); for (const task of tasks) { if (task.target_url) { const normalized = normalizeUrlForDedupe(task.target_url); urlSet.add(normalized); } } return urlSet; } catch (error) { console.error('[Money Pages Tasks] Error fetching existing tasks:', error); return new Set(); // Return empty set on error } } // Normalize URL for de-dupe matching (reusable helper) function normalizeUrlForDedupe(url) { if (!url) return ''; let normalized = url.trim(); // Ensure leading https://www.alanranger.com if stored as path if (normalized.startsWith('/')) { normalized = 'https://www.alanranger.com' + normalized; } else if (!normalized.startsWith('http')) { normalized = 'https://www.alanranger.com/' + normalized; } // Remove trailing slash (except root) if (normalized.endsWith('/') && normalized !== 'https://www.alanranger.com/') { normalized = normalized.slice(0, -1); } // Lowercase hostname only (keep path case as-is) try { const urlObj = new URL(normalized); normalized = urlObj.origin.toLowerCase() + urlObj.pathname + urlObj.search + urlObj.hash; } catch (e) { // If URL parsing fails, just lowercase the whole thing normalized = normalized.toLowerCase(); } return normalized; } // Normalize GSC page URL for page key matching (dedicated helper for page-level totals) // This avoids mismatches caused by full URL vs path-only, trailing slashes, query strings/fragments function normalizeGscPageKey(inputUrl) { if (!inputUrl) return ""; let u = ("" + inputUrl).trim(); // Accept path-only if (u.startsWith("/")) u = "https://www.alanranger.com" + u; else if (!u.startsWith("http")) u = "https://www.alanranger.com/" + u.replace(/^\/+/, ""); try { const urlObj = new URL(u); let path = urlObj.pathname || "/"; // remove trailing slash (except root) if (path.length > 1) path = path.replace(/\/+$/, ""); return urlObj.origin.toLowerCase() + path; } catch { // fallback: best-effort normalise return u.toLowerCase().replace(/\/+$/, ""); } } // Get task identity key for Money Page (reusable helper) function getMoneyPageTaskKey(pageUrl) { return normalizeUrlForDedupe(pageUrl); } // Find existing optimisation task for URL (reusable helper) function findExistingOptimisationTaskForUrl(tasks, pageUrl) { if (!tasks || !Array.isArray(tasks)) return null; const normalizedUrl = normalizeUrlForDedupe(pageUrl); return tasks.find(task => { if (!task.target_url) return false; return normalizeUrlForDedupe(task.target_url) === normalizedUrl; }) || null; } // Make helper functions globally available (reusable by Ranking & AI and Money Pages) window.getMoneyPageTaskKey = getMoneyPageTaskKey; window.findExistingOptimisationTaskForUrl = findExistingOptimisationTaskForUrl; window.normalizeUrlForDedupe = normalizeUrlForDedupe; window.normalizeGscPageKey = normalizeGscPageKey; // Create optimisation task for URL (reusable helper - used by both Money Pages and Ranking & AI) async function createOptimisationTaskForUrl(pageUrl, pageTitle, source = 'money_pages') { const adminKey = window.getAdminKey ? window.getAdminKey() : (sessionStorage.getItem('arp_admin_key') || localStorage.getItem('arp_admin_key') || ''); if (!adminKey) { throw new Error('Admin key required'); } const normalizedUrl = normalizeUrlForDedupe(pageUrl); const title = pageTitle || normalizedUrl; // Build task payload const taskPayload = { keyword_text: normalizedUrl, // API requires non-empty, use URL as placeholder for page-level tasks target_url: normalizedUrl, task_type: 'on_page', status: 'planned', title: source === 'money_pages' ? `MP: ${title}` : title, source: source, is_test: false }; // Create task const taskResponse = await fetch('/api/optimisation/task', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-arp-admin-key': adminKey }, body: JSON.stringify(taskPayload) }); if (!taskResponse.ok) { const errorText = await taskResponse.text(); throw new Error(`Failed to create task: ${errorText}`); } const taskData = await taskResponse.json(); const taskId = taskData.task?.id || taskData.id; if (!taskId) { throw new Error('Task created but no ID returned'); } // Create Cycle 1 with absolute CTR objective (2.5% = 0.025 ratio) await createCycleWithAbsoluteCtrObjective(taskId, adminKey); return taskData; } // Make createOptimisationTaskForUrl globally available window.createOptimisationTaskForUrl = createOptimisationTaskForUrl; // Open Track Money Page Modal (reuses Ranking & AI modal pattern) window.openTrackMoneyPageModal = function openTrackMoneyPageModal(row) { const modal = document.getElementById('optimisation-track-modal'); if (!modal) { console.error('[Money Pages] Track modal not found'); alert('Track modal not found. Please refresh the page.'); return; } // Get and clean URL const rawUrl = row.url || ''; const cleanedUrl = window.cleanUrlForDisplay ? window.cleanUrlForDisplay(rawUrl) : rawUrl; // For Money Pages, keyword is empty (page-level task) document.getElementById('track-keyword-text').textContent = '(Page-level task)'; // Make URL clickable in Track modal const trackUrlContainer = document.getElementById('track-url-text'); trackUrlContainer.innerHTML = ''; if (cleanedUrl) { let fullUrl = cleanedUrl; if (!fullUrl.startsWith('http://') && !fullUrl.startsWith('https://')) { fullUrl = 'https://' + fullUrl; } const urlLink = document.createElement('a'); urlLink.href = fullUrl; urlLink.target = '_blank'; urlLink.rel = 'noopener noreferrer'; urlLink.textContent = cleanedUrl; urlLink.style.color = '#0284c7'; urlLink.style.textDecoration = 'none'; urlLink.style.fontWeight = '600'; urlLink.style.wordBreak = 'break-all'; urlLink.addEventListener('mouseenter', () => { urlLink.style.textDecoration = 'underline'; }); urlLink.addEventListener('mouseleave', () => { urlLink.style.textDecoration = 'none'; }); trackUrlContainer.appendChild(urlLink); } else { trackUrlContainer.textContent = 'No URL available'; } // Set task type to on_page document.getElementById('track-task-type').value = 'on_page'; document.getElementById('track-status').value = 'planned'; // Prefill title const pageTitle = row.title || cleanedUrl; document.getElementById('track-title').value = `MP: ${pageTitle}`; document.getElementById('track-notes').value = ''; // Prefill objective for Money Pages (CTR ≥ 2.5%) document.getElementById('track-objective-title').value = 'CTR ≥ 2.5%'; document.getElementById('track-primary-kpi').value = 'ctr_28d'; document.getElementById('track-target-direction').value = 'at_least'; document.getElementById('track-target-value').value = '2.5'; document.getElementById('track-timeframe-days').value = ''; document.getElementById('track-plan').value = 'Created from Money Pages segment (CTR target: ≥ 2.5%).'; // Store row data for baseline metrics (needed in submitTrackKeyword) modal.dataset.rowKeyword = ''; // Empty for page-level tasks modal.dataset.rowUrl = cleanedUrl; modal.dataset.taskType = 'on_page'; modal.dataset.source = 'money_pages'; modal.dataset.rowData = JSON.stringify(row); // Update modal title and subtitle to indicate Money Pages const modalTitle = modal.querySelector('h3'); if (modalTitle) { modalTitle.textContent = 'Create optimisation task'; // Add subtitle if it doesn't exist let subtitle = modal.querySelector('#money-pages-modal-subtitle'); if (!subtitle) { subtitle = document.createElement('div'); subtitle.id = 'money-pages-modal-subtitle'; subtitle.style.cssText = 'margin: -0.5rem 0 1rem 0; font-size: 0.875rem; color: #64748b; font-weight: 400;'; modalTitle.insertAdjacentElement('afterend', subtitle); } subtitle.textContent = 'Money Pages → CTR target (absolute): ≥ 2.5%'; } // Add baseline preview section if it doesn't exist let baselinePreview = modal.querySelector('#money-pages-baseline-preview'); if (!baselinePreview) { baselinePreview = document.createElement('div'); baselinePreview.id = 'money-pages-baseline-preview'; baselinePreview.style.cssText = 'margin-bottom: 1.5rem; padding: 1rem; background: #fef3c7; border-radius: 4px; border: 1px solid #fbbf24;'; const objectiveSection = modal.querySelector('#track-objective-title').closest('div').parentElement; objectiveSection.insertAdjacentElement('beforebegin', baselinePreview); } // Populate baseline preview // Debug: Log row data to see what we have console.log('[Money Pages Modal] Row data for baseline:', { clicks: row.clicks, impressions: row.impressions, ctr: row.ctr, avgPosition: row.avgPosition, position: row.position, url: row.url }); const ctrPct = (row.ctr || 0) * 100; const clicks = row.clicks || row.clicks_28d || 0; const impressions = row.impressions || row.impressions_28d || 0; const ctr = row.ctr != null ? row.ctr : (row.ctr_28d || 0); const position = row.avgPosition != null ? row.avgPosition : (row.position || row.position_28d || null); baselinePreview.innerHTML = `
Baseline that will be captured on create:
Clicks (28d): ${clicks.toLocaleString()}
Impressions (28d): ${impressions.toLocaleString()}
CTR (28d): ${(ctr * 100).toFixed(2)}%
Avg position (28d): ${position ? position.toFixed(1) : 'N/A'}
`; modal.style.display = 'flex'; }; // Track Money Page - per-row function (opens modal instead of auto-creating) window.trackMoneyPage = function(pageUrl, pageTitle) { try { console.log('[Money Pages] trackMoneyPage called', { pageUrl, pageTitle }); debugLog(`📊 Opening Track modal for Money Page: ${pageUrl}`, 'info'); // Ensure the map exists (initialize if needed) if (!window.moneyPagesRowDataByUrl) { window.moneyPagesRowDataByUrl = new Map(); console.log('[Money Pages] Initialized window.moneyPagesRowDataByUrl map'); } // Look up row data from stored map (most reliable - populated during table render) let rowData = null; const normalizedUrl = normalizeUrlForDedupe ? normalizeUrlForDedupe(pageUrl) : pageUrl.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, ''); console.log('[Money Pages] Looking up row data for normalized URL:', normalizedUrl); if (window.moneyPagesRowDataByUrl.size > 0) { rowData = window.moneyPagesRowDataByUrl.get(normalizedUrl); console.log('[Money Pages] Row data from map:', rowData ? '✓ FOUND' : '✗ NOT FOUND'); if (rowData) { console.log('[Money Pages] ✓ Row data metrics from map:', { clicks: rowData.clicks, impressions: rowData.impressions, ctr: rowData.ctr, avgPosition: rowData.avgPosition, position: rowData.position, url: rowData.url }); // Validate that we have actual metrics (not all zeros) if (rowData.clicks === 0 && rowData.impressions === 0) { console.warn('[Money Pages] ⚠️ WARNING: Row data has 0 clicks and 0 impressions! This might be incorrect.'); debugLog(`⚠ Row data has 0 metrics - clicks=${rowData.clicks}, impressions=${rowData.impressions}`, 'warn'); } else { console.log('[Money Pages] ✓ Row data has valid metrics'); } } else { console.log('[Money Pages] Row data not found in map. Map size:', window.moneyPagesRowDataByUrl.size); // Log sample keys for debugging const sampleKeys = Array.from(window.moneyPagesRowDataByUrl.keys()).slice(0, 5); console.log('[Money Pages] Sample keys in map:', sampleKeys); } } else { console.log('[Money Pages] Map is empty, will search in moneyPagesMetrics.rows'); } // Method 2: If not found in map, search directly in moneyPagesMetrics.rows if (!rowData && window.moneyPagesMetrics && window.moneyPagesMetrics.rows) { console.log('[Money Pages] Searching in moneyPagesMetrics.rows...'); const normalizedUrl = normalizeUrlForDedupe ? normalizeUrlForDedupe(pageUrl) : pageUrl.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, ''); const matchingRow = window.moneyPagesMetrics.rows.find(row => { const rowNormalized = normalizeUrlForDedupe ? normalizeUrlForDedupe(row.url) : row.url.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, ''); return rowNormalized === normalizedUrl; }); if (matchingRow) { console.log('[Money Pages] Found row in moneyPagesMetrics.rows:', matchingRow); rowData = { url: matchingRow.url, title: matchingRow.title || pageTitle, clicks: matchingRow.clicks || 0, impressions: matchingRow.impressions || 0, ctr: matchingRow.ctr || 0, avgPosition: matchingRow.avgPosition != null ? matchingRow.avgPosition : (matchingRow.position || null), position: matchingRow.position || matchingRow.avgPosition || null, segment: matchingRow.segment || 'money_pages', metaDescription: matchingRow.metaDescription || null }; console.log('[Money Pages] Extracted row data:', rowData); } else { console.log('[Money Pages] Row not found in moneyPagesMetrics.rows. Total rows:', window.moneyPagesMetrics.rows.length); // Log sample URLs for debugging const sampleUrls = window.moneyPagesMetrics.rows.slice(0, 5).map(r => r.url); console.log('[Money Pages] Sample URLs in rows:', sampleUrls); } } // Method 3: Fallback - create minimal row data if (!rowData) { console.warn('[Money Pages] ⚠️ Row data not found anywhere! Using fallback with 0 metrics.'); debugLog(`⚠ Row data not found for ${pageUrl}, using fallback data (metrics will be 0)`, 'warn'); rowData = { url: pageUrl, title: pageTitle, clicks: 0, impressions: 0, ctr: 0, avgPosition: null }; } // Open modal with row data if (window.openTrackMoneyPageModal) { console.log('[Money Pages] Opening modal with row data:', rowData); window.openTrackMoneyPageModal(rowData); } else { console.error('[Money Pages] openTrackMoneyPageModal function not found!'); alert('Error: Modal function not found. Please refresh the page.'); } } catch (error) { console.error('[Money Pages] Error in trackMoneyPage:', error); debugLog(`✗ Error opening Track modal: ${error.message}`, 'error'); showStatus(`✗ Failed to open modal: ${error.message}`, 'error'); } }; // Step 5: Build optimisation task payload from Money Page row // CTR target: 2.5% absolute (stored as 0.025 ratio) function buildOptimizationTaskFromMoneyPage(row) { const normalizedUrl = normalizeUrlForDedupe(row.url); const title = row.page_title || row.slug || normalizedUrl; return { keyword_text: normalizedUrl, // Use URL as keyword (API requires non-empty, but task is page-level) target_url: normalizedUrl, task_type: 'on_page', status: 'planned', title: `MP: ${title}`, // Don't provide objective fields here - we'll create Cycle 1 manually with proper objective // This avoids creating a cycle with legacy fields }; } // Create cycle with absolute CTR objective async function createCycleWithAbsoluteCtrObjective(taskId, adminKey) { // CTR target: 2.5% = 0.025 as ratio (CTR is stored as 0-1 in DB) const ctrTargetRatio = 0.025; const objectiveData = { title: 'CTR ≥ 2.5%', kpi: 'ctr_28d', target: ctrTargetRatio, target_type: 'absolute', due_at: null, plan: 'Created from Money Pages segment (CTR target: ≥ 2.5%).' }; const response = await fetch(`/api/optimisation/task/${taskId}/cycle`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-arp-admin-key': adminKey }, body: JSON.stringify({ objective: objectiveData }) }); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to create cycle: ${error}`); } return await response.json(); } // Step 3 & 5: Create optimisation tasks for Money Pages async function createOptimizationTasksFromMoneyPages() { try { // Step 1: Fetch all Money Pages (ignoring filters) console.info('[Money Pages Tasks] Fetching all money pages...'); const moneyPages = await fetchMoneyPagesSegmentAll(); console.info(`[Money Pages Tasks] Found ${moneyPages.length} money pages`); if (moneyPages.length === 0) { alert('No money pages data available. Please run an audit first.'); return; } // Step 2: Fetch existing task URLs for de-dupe console.info('[Money Pages Tasks] Fetching existing task URLs...'); const existingUrls = await fetchExistingOptimizationTaskUrls(); console.info(`[Money Pages Tasks] Found ${existingUrls.size} existing tasks`); // Step 3: Filter out duplicates const newPages = []; const skippedPages = []; for (const page of moneyPages) { const normalizedUrl = normalizeUrlForDedupe(page.url); if (existingUrls.has(normalizedUrl)) { skippedPages.push(page); } else { newPages.push(page); } } console.info(`[Money Pages Tasks] ${newPages.length} new tasks to create, ${skippedPages.length} duplicates to skip`); // Show confirmation modal const confirmed = await showCreateTasksModal(newPages.length, skippedPages.length); if (!confirmed) { return; } // Step 4: Create tasks const adminKey = window.getAdminKey ? window.getAdminKey() : (sessionStorage.getItem('arp_admin_key') || localStorage.getItem('arp_admin_key') || ''); if (!adminKey) { throw new Error('Admin key required. Please set your admin key in the configuration section.'); } let createdCount = 0; let errorCount = 0; const createdTasks = []; for (const page of newPages) { try { const taskPayload = buildOptimizationTaskFromMoneyPage(page); // Create task const taskResponse = await fetch('/api/optimisation/task', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-arp-admin-key': adminKey }, body: JSON.stringify(taskPayload) }); if (!taskResponse.ok) { const error = await taskResponse.text(); throw new Error(`Failed to create task: ${error}`); } const taskResult = await taskResponse.json(); const taskId = taskResult.task?.id; if (taskId) { // Create Cycle 1 with absolute CTR objective // Task creation API doesn't create a cycle if we don't provide objective fields try { await createCycleWithAbsoluteCtrObjective(taskId, adminKey); } catch (cycleError) { console.warn(`[Money Pages Tasks] Cycle creation failed for task ${taskId}, but task was created:`, cycleError); // Continue - task is created even if cycle creation fails } createdCount++; if (createdTasks.length < 3) { createdTasks.push(taskPayload); } } } catch (error) { console.error(`[Money Pages Tasks] Error creating task for ${page.url}:`, error); errorCount++; } } // Step 7: Show toast and refresh showCreateTasksToast(createdCount, skippedPages.length, errorCount); // Log summary (dev-only) console.info('[Money Pages Tasks] Summary:', { totalMoneyPages: moneyPages.length, duplicatesFound: skippedPages.length, insertedCount: createdCount, errorCount: errorCount, firstThreePayloads: createdTasks }); // Refresh Optimisation Tracking if available if (typeof window.loadAllOptimisationTasks === 'function') { await window.loadAllOptimisationTasks(); } } catch (error) { console.error('[Money Pages Tasks] Error:', error); alert(`Error creating tasks: ${error.message}`); } } // Show confirmation modal function showCreateTasksModal(newCount, skippedCount) { return new Promise((resolve) => { const modal = document.createElement('div'); modal.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 10000; display: flex; align-items: center; justify-content: center;'; modal.innerHTML = `

Create optimisation tasks for ${newCount} money pages

This will create one optimisation task per money page with:

Existing tasks are skipped: ${skippedCount} duplicate${skippedCount !== 1 ? 's' : ''} found.

`; document.body.appendChild(modal); modal.querySelector('#create-tasks-cancel').onclick = () => { document.body.removeChild(modal); resolve(false); }; modal.querySelector('#create-tasks-confirm').onclick = () => { document.body.removeChild(modal); resolve(true); }; }); } // Show toast notification function showCreateTasksToast(created, skipped, errors) { const toast = document.createElement('div'); toast.style.cssText = 'position: fixed; bottom: 2rem; right: 2rem; background: #10b981; color: white; padding: 1rem 1.5rem; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.2); z-index: 10001; max-width: 400px;'; let message = `Created ${created} task${created !== 1 ? 's' : ''}`; if (skipped > 0) { message += ` • Skipped ${skipped} duplicate${skipped !== 1 ? 's' : ''}`; } if (errors > 0) { message += ` • ${errors} error${errors !== 1 ? 's' : ''}`; } toast.innerHTML = `
${message}
`; document.body.appendChild(toast); toast.querySelector('#toast-close').onclick = () => { document.body.removeChild(toast); }; toast.querySelector('#toast-view-tasks').onclick = () => { // Navigate to Optimisation Tracking module if (typeof setActivePanel === 'function') { setActivePanel('optimisation'); } document.body.removeChild(toast); }; // Auto-remove after 5 seconds setTimeout(() => { if (document.body.contains(toast)) { document.body.removeChild(toast); } }, 5000); } // Wire up button click handler (called after Money Pages section is rendered) function wireMoneyPagesCreateTasksButton() { const btn = document.getElementById('money-pages-create-tasks-btn'); if (btn && !btn.dataset.wired) { btn.dataset.wired = 'true'; btn.addEventListener('click', () => { createOptimizationTasksFromMoneyPages(); }); } } // Helper function to filter money pages data by sub-segment // Apply all filters and calculate filtered metrics function getFilteredMoneyPagesMetrics(moneyPagesMetrics) { if (!moneyPagesMetrics || !moneyPagesMetrics.rows) { return null; } const currentCategoryFilter = String(window.moneyPagesCategoryFilter || 'ALL').toUpperCase(); const currentSubSegmentFilter = String(window.moneyPagesSubSegmentFilter || 'ALL').toUpperCase(); const currentMinImpressions = Number(window.moneyPagesMinImpressions || 0) || 0; const includeZero = window.moneyPagesIncludeZero !== false; // Apply all filters const filteredRows = moneyPagesMetrics.rows.filter(row => { const rowCat = String(row.category || '').toUpperCase(); const rowSub = String(row.subSegment || row.segmentType || '').toUpperCase(); const matchCat = currentCategoryFilter === 'ALL' || rowCat === currentCategoryFilter; const matchSubSeg = currentSubSegmentFilter === 'ALL' || rowSub === currentSubSegmentFilter; const matchImp = (row.impressions || 0) >= currentMinImpressions; const matchZero = includeZero || (row.impressions || 0) > 0; return matchCat && matchSubSeg && matchImp && matchZero; }); // Recalculate summaryByCategory from filtered rows const filteredSummaryByCategory = { HIGH_OPPORTUNITY: { count: 0, impressions: 0, clicks: 0 }, VISIBILITY_FIX: { count: 0, impressions: 0, clicks: 0 }, MAINTAIN: { count: 0, impressions: 0, clicks: 0 } }; let filteredClicks = 0; let filteredImpressions = 0; let filteredWeightedPosSum = 0; filteredRows.forEach(row => { const bucket = filteredSummaryByCategory[row.category]; if (bucket) { bucket.count += 1; bucket.impressions += row.impressions || 0; bucket.clicks += row.clicks || 0; } filteredClicks += row.clicks || 0; filteredImpressions += row.impressions || 0; filteredWeightedPosSum += (row.avgPosition || 0) * (row.impressions || 0); }); const filteredCtr = filteredImpressions > 0 ? filteredClicks / filteredImpressions : 0; const filteredAvgPosition = filteredImpressions > 0 ? filteredWeightedPosSum / filteredImpressions : null; return { ...moneyPagesMetrics, rows: filteredRows, summaryByCategory: filteredSummaryByCategory, overview: { ...moneyPagesMetrics.overview, moneyClicks: filteredClicks, moneyImpressions: filteredImpressions, moneyCtr: filteredCtr, moneyAvgPosition: filteredAvgPosition, moneyCoverageCount: filteredRows.length } }; } // Get filter description for chart title function getFilterDescription() { const categoryFilter = window.moneyPagesCategoryFilter || 'ALL'; const subSegmentFilter = window.moneyPagesSubSegmentFilter || 'ALL'; const minImpressions = window.moneyPagesMinImpressions || 0; const includeZero = window.moneyPagesIncludeZero !== false; const parts = []; if (categoryFilter !== 'ALL') { const categoryLabels = { 'HIGH_OPPORTUNITY': 'High opportunity', 'VISIBILITY_FIX': 'Visibility fix', 'MAINTAIN': 'Maintain' }; parts.push(categoryLabels[categoryFilter] || categoryFilter); } if (subSegmentFilter !== 'ALL') { const subSegmentLabels = { 'PRODUCT': 'Product', 'EVENT': 'Event', 'LANDING': 'Landing' }; parts.push(subSegmentLabels[subSegmentFilter] || subSegmentFilter); } if (minImpressions > 0) { parts.push(`≥${minImpressions} impressions`); } if (!includeZero) { parts.push('with impressions'); } if (parts.length === 0) { return 'Money Pages'; } return parts.join(' ') + ' Pages'; } // Ensure this helper exists in the global scope (Money tab can run before other sections) if (!window.computeMoneyPagesBehaviourFromPageAggregates) { window.computeMoneyPagesBehaviourFromPageAggregates = function(moneyPages) { if (!Array.isArray(moneyPages) || moneyPages.length === 0) return null; let clicks = 0; let impressions = 0; let weightedPosSum = 0; let weightedPosImps = 0; let top10Clicks = 0; let top10Impressions = 0; moneyPages.forEach(p => { const imps = p.impressions || 0; const cls = p.clicks || 0; const pos = typeof p.avgPosition === 'number' ? p.avgPosition : (p.avgPosition ? Number.parseFloat(p.avgPosition) : null); if (!imps || !pos || pos <= 0) return; clicks += cls; impressions += imps; weightedPosSum += pos * imps; weightedPosImps += imps; if (pos <= 10) { top10Clicks += cls; top10Impressions += imps; } }); if (!impressions || !weightedPosImps) return null; const siteCtr = clicks / impressions; const top10Ctr = top10Impressions > 0 ? (top10Clicks / top10Impressions) : 0; const avgPos = weightedPosSum / weightedPosImps; const top10Share = impressions > 0 ? (top10Impressions / impressions) : 0; const scoreCtrAll = Math.min(siteCtr / 0.05, 1) * 100; const scoreCtrTop10 = Math.min(top10Ctr / 0.10, 1) * 100; const behaviourScore = 0.5 * scoreCtrAll + 0.5 * scoreCtrTop10; return { score: behaviourScore, siteCtr, top10Ctr, avgPos, top10Share, clicks, impressions }; }; } // Update chart title and summary box function updateMoneyPagesChartSummary(filteredMetrics) { if (!filteredMetrics) return; // Update chart title const titleEl = document.getElementById('money-pages-chart-title'); if (titleEl) { const filterDesc = getFilterDescription(); titleEl.textContent = (filterDesc || 'Money Pages') + ' Opportunity Mix'; } // Calculate behaviour score for filtered pages const queryPages = window.currentQueryPages || []; const filteredRows = filteredMetrics.rows || []; let filteredBehaviour = window.computeMoneyPagesBehaviour ? window.computeMoneyPagesBehaviour(queryPages, filteredRows, true) : null; if (!filteredBehaviour || !filteredBehaviour.impressions) { const aggFn = window.computeMoneyPagesBehaviourFromPageAggregates; filteredBehaviour = typeof aggFn === 'function' ? aggFn(filteredRows) : null; } // Update summary box const overview = filteredMetrics.overview || {}; const pagesCount = filteredRows.length; const impressions = overview.moneyImpressions || 0; const clicks = overview.moneyClicks || 0; const ctr = overview.moneyCtr || 0; const behaviourScore = filteredBehaviour ? filteredBehaviour.score : null; const pagesEl = document.getElementById('summary-pages-count'); const impressionsEl = document.getElementById('summary-impressions'); const clicksEl = document.getElementById('summary-clicks'); const ctrEl = document.getElementById('summary-ctr'); const behaviourEl = document.getElementById('summary-behaviour'); if (pagesEl) pagesEl.textContent = pagesCount.toLocaleString(); if (impressionsEl) impressionsEl.textContent = impressions.toLocaleString(); if (clicksEl) clicksEl.textContent = clicks.toLocaleString(); // Update CTR with RAG color coding if (ctrEl) { if (impressions > 0) { const ctrPercent = ctr * 100; ctrEl.textContent = `${ctrPercent.toFixed(1)}%`; // Apply RAG color: Green >= 2.5%, Amber 1.0-2.49%, Red < 1.0% if (ctrPercent >= 2.5) { ctrEl.style.color = '#10b981'; // green } else if (ctrPercent >= 1.0) { ctrEl.style.color = '#f59e0b'; // amber } else { ctrEl.style.color = '#ef4444'; // red } } else { ctrEl.textContent = '-'; ctrEl.style.color = '#64748b'; } } // Update Behaviour Score with RAG color coding if (behaviourEl) { if (behaviourScore !== null && behaviourScore !== undefined) { behaviourEl.textContent = Math.round(behaviourScore); // Apply RAG color: Green >= 70, Amber 40-69, Red < 40 if (behaviourScore >= 70) { behaviourEl.style.color = '#10b981'; // green } else if (behaviourScore >= 40) { behaviourEl.style.color = '#f59e0b'; // amber } else { behaviourEl.style.color = '#ef4444'; // red } } else { behaviourEl.textContent = 'N/A'; behaviourEl.style.color = '#64748b'; } } } // Render Money Pages category chart (Phase 2) let moneyPagesCategoryChart = null; function renderMoneyPagesCategoryChart(moneyPagesMetrics, retryCount = 0) { const summaryEl = document.getElementById('money-pages-category-summary'); const breakdownEl = document.getElementById('money-pages-category-breakdown'); // Show loading placeholder early (prevents blank card) if (retryCount === 0 && summaryEl && !summaryEl.textContent) { summaryEl.textContent = 'Loading opportunity mix…'; } // Prevent excessive retries (show user-facing message instead of blank canvas) if (retryCount > 8) { debugLog('⚠ Max retries reached for Money Pages Opportunity Mix chart', 'warn'); if (summaryEl) { summaryEl.textContent = 'Could not render Opportunity Mix chart. Try switching tabs or refreshing.'; summaryEl.style.color = '#b45309'; } if (breakdownEl) breakdownEl.innerHTML = ''; return; } debugLog(`renderMoneyPagesCategoryChart called (attempt ${retryCount + 1})`, 'info'); // Check if Chart.js is available if (typeof Chart === 'undefined') { debugLog('⚠ Chart.js not loaded, cannot render money pages chart', 'warn'); // Retry after a delay if Chart.js might still be loading if (retryCount < 3) { setTimeout(() => renderMoneyPagesCategoryChart(moneyPagesMetrics, retryCount + 1), 500); } return; } const canvas = document.getElementById('money-pages-category-chart'); if (!canvas) { debugLog(`⚠ Money pages chart canvas not found (attempt ${retryCount + 1})`, 'warn'); // Retry if canvas doesn't exist yet (DOM might not be ready) if (retryCount < 5) { setTimeout(() => renderMoneyPagesCategoryChart(moneyPagesMetrics, retryCount + 1), 200); } return; } if (!moneyPagesMetrics || !moneyPagesMetrics.summaryByCategory) { debugLog('⚠ Money pages metrics or summaryByCategory missing', 'warn'); // Hide chart row if no data const chartRow = document.getElementById('money-pages-chart-row'); if (chartRow) { chartRow.style.display = 'none'; } if (summaryEl) summaryEl.textContent = 'No opportunity mix data available for this audit.'; return; } // Apply all filters const filteredMetrics = getFilteredMoneyPagesMetrics(moneyPagesMetrics); if (!filteredMetrics) { debugLog('⚠ No filtered metrics available', 'warn'); return; } const { summaryByCategory } = filteredMetrics; debugLog(`Money pages summaryByCategory: ${JSON.stringify(summaryByCategory)}`, 'info'); const labels = [ 'High opportunity', 'Visibility fix', 'Maintain' ]; const keys = ['HIGH_OPPORTUNITY', 'VISIBILITY_FIX', 'MAINTAIN']; const counts = keys.map(k => (summaryByCategory[k]?.count || 0)); const impressions = keys.map(k => (summaryByCategory[k]?.impressions || 0)); debugLog(`Money pages chart data - counts: [${counts.join(', ')}], impressions: [${impressions.join(', ')}]`, 'info'); // Check for no data const totalPages = counts.reduce((a, b) => a + b, 0); const totalImpressions = impressions.reduce((a, b) => a + b, 0); function setNoDataMessage() { if (summaryEl) { summaryEl.textContent = 'No meaningful money-page impressions found for this audit period.'; } if (breakdownEl) { breakdownEl.innerHTML = ''; } } if (!totalPages || !totalImpressions) { debugLog('⚠ No money pages data for chart', 'warn'); const chartRow = document.getElementById('money-pages-chart-row'); if (chartRow) { const canvas = chartRow.querySelector('#money-pages-category-chart'); if (canvas) canvas.remove(); setNoDataMessage(); } return; } // Show chart row and ensure it's visible const chartRow = document.getElementById('money-pages-chart-row'); if (!chartRow) { debugLog('⚠ Chart row element not found', 'warn'); // Retry if chart row doesn't exist yet if (retryCount < 3) { setTimeout(() => renderMoneyPagesCategoryChart(moneyPagesMetrics, retryCount + 1), 200); } return; } // Ensure chart row is visible chartRow.style.display = 'block'; chartRow.style.visibility = 'visible'; debugLog('✓ Chart row is visible', 'success'); // Re-check canvas after ensuring row is visible (in case it wasn't accessible before) const canvasCheck = document.getElementById('money-pages-category-chart'); if (!canvasCheck) { debugLog('⚠ Canvas still not found after showing row, retrying...', 'warn'); if (retryCount < 3) { setTimeout(() => renderMoneyPagesCategoryChart(moneyPagesMetrics, retryCount + 1), 200); } return; } // Destroy existing chart if it exists to prevent memory leaks // Also check Chart.js registry to ensure canvas is not already in use if (moneyPagesCategoryChart) { try { moneyPagesCategoryChart.destroy(); moneyPagesCategoryChart = null; } catch (e) { debugLog('Error destroying existing chart: ' + e.message, 'warn'); } } // Also check Chart.js registry and destroy any existing chart on this canvas if (typeof Chart !== 'undefined' && Chart.getChart) { const existingChart = Chart.getChart(canvas); if (existingChart) { try { existingChart.destroy(); debugLog('Destroyed existing Chart.js instance from registry', 'info'); } catch (e) { debugLog('Error destroying chart from registry: ' + e.message, 'warn'); } } } // Clear canvas context to ensure clean slate const tempCtx = canvas.getContext('2d'); if (tempCtx) { tempCtx.clearRect(0, 0, canvas.width, canvas.height); } // Set explicit canvas dimensions FIRST to ensure it has size // This must happen before checking dimensions const parentContainer = canvas.parentElement; if (parentContainer) { const containerWidth = parentContainer.clientWidth || parentContainer.offsetWidth || 500; const containerHeight = 300; // Fixed height for consistency canvas.width = containerWidth; canvas.height = containerHeight; } else { canvas.width = 500; canvas.height = 300; } // Check if panel/canvas are actually visible and have dimensions AFTER setting them const moneyPanel = canvas.closest('.aigeo-panel[data-panel="money"]'); const isPanelActive = !moneyPanel || moneyPanel.classList.contains('is-active'); const isPanelVisible = !moneyPanel || (isPanelActive && window.getComputedStyle(moneyPanel).display !== 'none'); const canvasRect = canvas.getBoundingClientRect(); const hasDimensions = canvasRect.width > 0 && canvasRect.height > 0; if (!isPanelActive) { debugLog(`⚠ Money Pages Opportunity Mix: panel not active yet, waiting…`, 'warn'); if (summaryEl) summaryEl.textContent = 'Loading opportunity mix…'; if (moneyPanel) { const observer = new MutationObserver(() => { if (moneyPanel.classList.contains('is-active')) { observer.disconnect(); requestAnimationFrame(() => renderMoneyPagesCategoryChart(moneyPagesMetrics, 0)); } }); observer.observe(moneyPanel, { attributes: true, attributeFilter: ['class'] }); } return; } if (!hasDimensions || !isPanelVisible) { debugLog(`⚠ Money Pages Opportunity Mix chart: panelVisible=${isPanelVisible} or canvas ${canvasRect.width}×${canvasRect.height} (width=${canvasRect.width}, height=${canvasRect.height}, display=${moneyPanel ? window.getComputedStyle(moneyPanel).display : 'N/A'}), retrying…`, 'warn'); setTimeout(() => renderMoneyPagesCategoryChart(moneyPagesMetrics, retryCount + 1), 300); return; } const ctx = canvas.getContext('2d'); if (!ctx) { debugLog('⚠ Could not get canvas context', 'warn'); return; } // Use impressions as weight for the chart slices const dataValues = impressions; debugLog(`Creating money pages category chart with data: [${dataValues.join(', ')}]`, 'info'); try { moneyPagesCategoryChart = new Chart(ctx, { type: 'doughnut', data: { labels, datasets: [ { data: dataValues, backgroundColor: [ '#f59e0b', // Amber for High opportunity '#ef4444', // Red for Visibility fix '#10b981' // Green for Maintain ], borderColor: [ '#d97706', '#dc2626', '#059669' ], borderWidth: 2 } ] }, options: { responsive: false, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { padding: 15, font: { size: 12 } } }, tooltip: { callbacks: { label: function (context) { const label = context.label || ''; const value = context.raw || 0; const total = dataValues.reduce((a, b) => a + b, 0) || 1; const pct = (value / total) * 100; const count = counts[context.dataIndex] || 0; return `${label}: ${value.toLocaleString()} impressions, ` + `${count} pages (${pct.toFixed(1)}%)`; } } } }, cutout: '60%' } }); debugLog('✓ Money pages category chart created successfully', 'success'); // Update chart title and summary box with filtered data updateMoneyPagesChartSummary(filteredMetrics); // Populate summary and breakdown after chart is created const totalPages = counts.reduce((a, b) => a + b, 0); const totalImpressions = impressions.reduce((a, b) => a + b, 0); const summaryEl = document.getElementById('money-pages-category-summary'); const breakdownEl = document.getElementById('money-pages-category-breakdown'); // 1) Summary line if (summaryEl && totalPages > 0 && totalImpressions > 0) { const highImps = impressions[0] || 0; const visImps = impressions[1] || 0; const mainImps = impressions[2] || 0; const mainBucket = highImps >= visImps && highImps >= mainImps ? 'high-opportunity' : visImps >= mainImps ? 'visibility-fix' : 'maintain'; const focusShare = (Math.max(highImps, visImps, mainImps) / totalImpressions) * 100; summaryEl.textContent = `${totalPages} money pages, ${totalImpressions.toLocaleString()} impressions. ` + `Largest share is ${mainBucket} pages ` + `(${focusShare.toFixed(1)}% of impressions).`; } // 2) Category pills row if (breakdownEl && totalImpressions > 0) { breakdownEl.innerHTML = ''; // clear const colors = ['pill-warning', 'pill-danger', 'pill-success']; // high, vis, maintain impressions.forEach((imps, idx) => { if (!imps) return; const count = counts[idx] || 0; const pct = (imps / totalImpressions) * 100; const pill = document.createElement('span'); pill.className = `pill pill-small ${colors[idx]}`; pill.style.cssText = 'display: inline-block; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; font-weight: 600; margin: 0.125rem;'; if (colors[idx] === 'pill-warning') { pill.style.background = '#fef3c7'; pill.style.color = '#92400e'; } else if (colors[idx] === 'pill-danger') { pill.style.background = '#fee2e2'; pill.style.color = '#991b1b'; } else { pill.style.background = '#d1fae5'; pill.style.color = '#065f46'; } pill.textContent = `${labels[idx]}: ${count} pages, ` + `${imps.toLocaleString()} imps (${pct.toFixed(1)}%)`; breakdownEl.appendChild(pill); }); } } catch (error) { debugLog(`✗ Error creating money pages chart: ${error.message}`, 'error'); console.error('Error creating money pages category chart:', error); } } // Phase 3: Render Money Pages Trend Chart function renderMoneyPagesTrendChart(history) { const ctx = document.getElementById('moneyPagesTrendChart'); if (!ctx) { debugLog('⚠ Money Pages trend chart canvas not found', 'warn'); return; } if (!history || !history.length) { debugLog('⚠ No history data for Money Pages trend chart', 'warn'); // Show message in chart container const cardBody = ctx.closest('.card-body'); if (cardBody) { cardBody.innerHTML = '
No historical audit data available. Run multiple audits to see trends over time.
'; } return; } // Check if Chart.js is available if (typeof Chart === 'undefined') { debugLog('⚠ Chart.js not loaded, cannot render Money Pages trend chart', 'warn'); return; } // Destroy existing chart if it exists if (window.moneyPagesTrendChart) { try { // Check if it's a Chart.js instance before calling destroy if (window.moneyPagesTrendChart && typeof window.moneyPagesTrendChart.destroy === 'function') { window.moneyPagesTrendChart.destroy(); } window.moneyPagesTrendChart = null; } catch (e) { debugLog('Error destroying existing trend chart: ' + e.message, 'warn'); } } // Extract money pages history from audit history const moneyHistory = history.map(row => ({ date: row.date, dateLabel: row.date ? new Date(row.date).toLocaleDateString('en-GB', { month: 'short', day: 'numeric' }) : row.date, behaviourScore: row.moneyPagesBehaviourScore ?? null, shareOfImpressions: row.moneyPagesSummary?.shareOfImpressions ?? null, shareOfClicks: row.moneyPagesSummary?.shareOfClicks ?? null, ctr: row.moneyPagesSummary?.ctr ?? null })).filter(h => h.date); // Filter out entries without dates debugLog(`📊 Money Pages Trend: Extracted ${moneyHistory.length} history entries from ${history.length} total records`, 'info'); if (moneyHistory.length > 0) { const sample = moneyHistory[0]; debugLog(`📊 Sample entry: date=${sample.date}, behaviourScore=${sample.behaviourScore}, shareOfImpressions=${sample.shareOfImpressions}, shareOfClicks=${sample.shareOfClicks}, ctr=${sample.ctr}`, 'info'); debugLog(`📊 Sample entry moneyPagesSummary: ${JSON.stringify(sample.moneyPagesSummary || 'missing')}`, 'info'); } else { debugLog(`⚠ Money Pages Trend: No valid history entries extracted. History records: ${history.length}`, 'warn'); if (history.length > 0) { debugLog(`⚠ Sample history record keys: ${Object.keys(history[0]).join(', ')}`, 'warn'); debugLog(`⚠ Sample history record: ${JSON.stringify(history[0])}`, 'warn'); } } // Add current audit data if available const currentMoneyPagesMetrics = window.currentMoneyPagesMetrics || (window.saved && window.saved.scores && window.saved.scores.moneyPagesMetrics); const currentSearchData = window.currentSearchData || (window.saved && window.saved.searchData); if (currentMoneyPagesMetrics && currentMoneyPagesMetrics.behaviour && currentMoneyPagesMetrics.behaviour.score != null) { const today = new Date().toISOString().split('T')[0]; const currentSummary = window.buildMoneyPagesSummary ? window.buildMoneyPagesSummary(currentMoneyPagesMetrics, currentSearchData?.overview || null) : null; // Check if today's data is already in history const todayInHistory = moneyHistory.find(h => h.date === today); if (!todayInHistory && currentSummary) { debugLog(`📊 Adding current audit data to Money Pages trend: date=${today}, behaviourScore=${currentMoneyPagesMetrics.behaviour.score}, shareOfImpressions=${currentSummary.shareOfImpressions}, ctr=${currentSummary.ctr}`, 'info'); debugLog(`📊 Current overview: ${JSON.stringify(currentSearchData?.overview || 'missing')}`, 'info'); moneyHistory.push({ date: today, dateLabel: new Date(today).toLocaleDateString('en-GB', { month: 'short', day: 'numeric' }), behaviourScore: currentMoneyPagesMetrics.behaviour.score, shareOfImpressions: currentSummary.shareOfImpressions, shareOfClicks: currentSummary.shareOfClicks, ctr: currentSummary.ctr }); } else if (todayInHistory && currentMoneyPagesMetrics.behaviour.score != null) { // Update today's entry with current data if it exists but is missing behaviour score if (todayInHistory.behaviourScore == null) { debugLog(`📊 Updating today's entry with current behaviour score: ${currentMoneyPagesMetrics.behaviour.score}`, 'info'); todayInHistory.behaviourScore = currentMoneyPagesMetrics.behaviour.score; if (currentSummary) { todayInHistory.shareOfImpressions = currentSummary.shareOfImpressions; todayInHistory.shareOfClicks = currentSummary.shareOfClicks; todayInHistory.ctr = currentSummary.ctr; } } } } // Sort by date moneyHistory.sort((a, b) => new Date(a.date) - new Date(b.date)); // Forward-fill missing values: use last available value for each metric let lastValues = { behaviourScore: null, shareOfImpressions: null, shareOfClicks: null, ctr: null }; for (const entry of moneyHistory) { // Forward-fill missing values if (entry.behaviourScore == null && lastValues.behaviourScore != null) { entry.behaviourScore = lastValues.behaviourScore; } if (entry.shareOfImpressions == null && lastValues.shareOfImpressions != null) { entry.shareOfImpressions = lastValues.shareOfImpressions; } if (entry.shareOfClicks == null && lastValues.shareOfClicks != null) { entry.shareOfClicks = lastValues.shareOfClicks; } if (entry.ctr == null && lastValues.ctr != null) { entry.ctr = lastValues.ctr; } // Update last values if we have new ones if (entry.behaviourScore != null) lastValues.behaviourScore = entry.behaviourScore; if (entry.shareOfImpressions != null) lastValues.shareOfImpressions = entry.shareOfImpressions; if (entry.shareOfClicks != null) lastValues.shareOfClicks = entry.shareOfClicks; if (entry.ctr != null) lastValues.ctr = entry.ctr; } if (moneyHistory.length === 0) { debugLog('⚠ No money pages history data after filtering', 'warn'); const cardBody = ctx.closest('.card-body'); if (cardBody) { cardBody.innerHTML = '
No money pages historical data available. Run audits to build trend data.
'; } return; } // Filter to only entries with at least one non-null value const validHistory = moneyHistory.filter(h => h.behaviourScore != null || h.shareOfImpressions != null || h.ctr != null ); if (validHistory.length === 0) { debugLog('⚠ No money pages history data with valid values', 'warn'); const cardBody = ctx.closest('.card-body'); if (cardBody) { cardBody.innerHTML = '
No money pages historical data available. Run audits to build trend data.
'; } return; } debugLog(`📊 Money Pages Trend: Using ${validHistory.length} entries with valid data`, 'info'); const labels = validHistory.map(p => p.dateLabel || p.date); const behaviour = validHistory.map(p => p.behaviourScore ?? null); const shareImps = validHistory.map(p => p.shareOfImpressions != null ? p.shareOfImpressions * 100 : null ); const ctr = validHistory.map(p => p.ctr != null ? p.ctr * 100 : null ); debugLog(`📊 Chart data: ${labels.length} labels, behaviour=${behaviour.filter(v => v != null).length} values, shareImps=${shareImps.filter(v => v != null).length} values, ctr=${ctr.filter(v => v != null).length} values`, 'info'); // Set canvas dimensions FIRST const parentContainer = ctx.parentElement; if (parentContainer) { const containerWidth = parentContainer.clientWidth || parentContainer.offsetWidth || 500; ctx.width = containerWidth; ctx.height = 300; } else { ctx.width = 500; ctx.height = 300; } // Check if canvas is visible and panel is active AFTER setting dimensions const moneyPanel = ctx.closest('.aigeo-panel[data-panel="money"]'); const isPanelActive = moneyPanel && moneyPanel.classList.contains('is-active'); const panelStyle = moneyPanel ? window.getComputedStyle(moneyPanel) : null; const isPanelVisible = !moneyPanel || (isPanelActive && panelStyle && panelStyle.display !== 'none' && panelStyle.visibility !== 'hidden'); const canvasRect = ctx.getBoundingClientRect(); // Wait a bit for layout to settle if panel was just activated if (!isPanelVisible || canvasRect.width === 0 || canvasRect.height === 0) { // Only log warning if panel is not active (to reduce noise) if (!isPanelActive) { debugLog(`⚠ Money Pages trend chart: Panel hidden or canvas has zero dimensions (width=${canvasRect.width}, height=${canvasRect.height}, panelVisible=${isPanelVisible}). Chart will render when panel is shown.`, 'warn'); // Schedule retry when panel becomes visible if (moneyPanel && !moneyPanel.classList.contains('is-active')) { // Use a one-time observer const observer = new MutationObserver((mutations) => { if (moneyPanel.classList.contains('is-active')) { observer.disconnect(); // Wait a bit longer for layout to settle setTimeout(() => renderMoneyPagesTrendChart(history), 200); } }); observer.observe(moneyPanel, { attributes: true, attributeFilter: ['class'] }); // Also set a timeout fallback (max 5 seconds) setTimeout(() => { observer.disconnect(); if (moneyPanel.classList.contains('is-active')) { renderMoneyPagesTrendChart(history); } }, 5000); } } else { // Panel is active but canvas still has zero dimensions - retry after a short delay setTimeout(() => renderMoneyPagesTrendChart(history), 200); } return; } try { window.moneyPagesTrendChart = new Chart(ctx, { type: 'line', data: { labels, datasets: [ { label: 'Behaviour score (money pages)', data: behaviour, yAxisID: 'yScore', borderColor: 'rgb(59, 130, 246)', backgroundColor: 'rgba(59, 130, 246, 0.1)', borderWidth: 2, spanGaps: true, tension: 0.1 }, { label: 'Share of impressions (%)', data: shareImps, yAxisID: 'yPct', borderColor: 'rgb(16, 185, 129)', backgroundColor: 'rgba(16, 185, 129, 0.1)', borderWidth: 2, borderDash: [4, 4], spanGaps: true, tension: 0.1 }, { label: 'CTR on money pages (%)', data: ctr, yAxisID: 'yPct', borderColor: 'rgb(245, 158, 11)', backgroundColor: 'rgba(245, 158, 11, 0.1)', borderWidth: 2, borderDash: [2, 2], spanGaps: true, tension: 0.1 } ] }, options: { responsive: true, maintainAspectRatio: true, aspectRatio: 2, scales: { yScore: { type: 'linear', position: 'left', min: 0, max: 100, title: { display: true, text: 'Behaviour score' }, grid: { color: 'rgba(0, 0, 0, 0.05)' } }, yPct: { type: 'linear', position: 'right', min: 0, max: 100, grid: { drawOnChartArea: false }, title: { display: true, text: 'Percent' } } }, plugins: { title: { display: false // Don't show title - it's already in the HTML }, legend: { position: 'bottom' }, tooltip: { mode: 'index', intersect: false, callbacks: { label: function(context) { let label = context.dataset.label || ''; if (label) { label += ': '; } const value = context.parsed.y; if (value === null || value === undefined) { label += 'N/A'; } else { label += value.toFixed(2); if (context.dataset.yAxisID === 'yPct') { label += '%'; } } return label; } } } } } }); debugLog(`✓ Money Pages trend chart rendered with ${moneyHistory.length} data points`, 'success'); } catch (error) { debugLog(`✗ Error creating Money Pages trend chart: ${error.message}`, 'error'); console.error('Error creating Money Pages trend chart:', error); } } // ============================================================================ // Money Pages Priority Matrix Rendering Functions // ============================================================================ /** * Render summary strip for Money Pages Priority section * @param {Array} moneyPages * @param {Object} summary - Overview data with totalClicks and totalImpressions * @param {HTMLElement} container */ function renderMoneyPagesSummaryStrip(moneyPages, summary, container) { if (!moneyPages || !moneyPages.length) { container.innerHTML = '
No money pages data available.
'; return; } const totalClicks = summary?.overview?.totalClicks || summary?.totalClicks || 0; const totalImpressions = summary?.overview?.totalImpressions || summary?.totalImpressions || 0; const mpClicks = moneyPages.reduce((sum, p) => sum + (p.clicks || 0), 0); const mpImpr = moneyPages.reduce((sum, p) => sum + (p.impressions || 0), 0); const shareClicks = totalClicks > 0 ? (mpClicks / totalClicks) * 100 : 0; const ctr = mpImpr > 0 ? (mpClicks / mpImpr) * 100 : 0; const avgPos = moneyPages.length ? moneyPages.reduce((sum, p) => sum + (p.avgPosition || 0), 0) / moneyPages.length : 0; container.innerHTML = `
Money pages share of clicks ${shareClicks.toFixed(1)}%
Money pages CTR ${ctr.toFixed(1)}%
Avg position (money pages) ${avgPos ? avgPos.toFixed(1) : "—"}
`; } /** * Render Priority Matrix (3x3 grid: Impact × Difficulty) * @param {Array} moneyPages * @param {HTMLElement} container * @param {Function} onCellClick - Callback(impact, difficulty) */ function renderMoneyPagesMatrix(moneyPages, container, onCellClick, activeFilter = null) { if (!moneyPages || !moneyPages.length) { container.innerHTML = '
No money pages data available.
'; return; } const impacts = ["HIGH", "MEDIUM", "LOW"]; const diffs = ["LOW", "MEDIUM", "HIGH"]; const totalClicks = moneyPages.reduce((sum, p) => sum + (p.clicks || 0), 0) || 1; let html = `
Impact ↑
`; for (const impact of impacts) { for (const diff of diffs) { const cellPages = moneyPages.filter( p => p.impactLevel === impact && p.difficultyLevel === diff ); const count = cellPages.length; const clickShare = cellPages.reduce((s, p) => s + (p.clicks || 0), 0) / totalClicks * 100; const ragClass = impact === "HIGH" && (diff === "LOW" || diff === "MEDIUM") ? "rag-high" : impact === "HIGH" && diff === "HIGH" ? "rag-medium" : "rag-low"; // Check if this cell is active const isActive = activeFilter && activeFilter.impact === impact && activeFilter.diff === diff; // Active state styling: thicker border, darker background const borderColor = isActive ? '#2563eb' : (ragClass === 'rag-high' ? '#10b981' : ragClass === 'rag-medium' ? '#f59e0b' : '#e5e7eb'); const borderWidth = isActive ? '4px' : '2px'; const backgroundColor = isActive ? '#dbeafe' : (ragClass === 'rag-high' ? '#ecfdf5' : ragClass === 'rag-medium' ? '#fffbeb' : '#f9fafb'); html += ` `; } } html += `
Difficulty →

Click a cell to filter the money page action list. Impact is based on lost clicks (expected CTR – actual). Difficulty is based on rank and schema effort.

`; container.innerHTML = html; container.querySelectorAll(".matrix-cell").forEach(btn => { btn.addEventListener("click", () => { const impact = /** @type {ImpactLevel} */ (btn.getAttribute("data-impact")); const diff = /** @type {DifficultyLevel} */ (btn.getAttribute("data-diff")); if (onCellClick) onCellClick(impact, diff); }); }); } // Make functions globally available window.renderMoneyPagesMatrix = renderMoneyPagesMatrix; window.renderMoneyPagesTable = renderMoneyPagesTable; window.renderMoneyPagesCategoryChart = renderMoneyPagesCategoryChart; window.updateMoneyPagesSummaryMetrics = updateMoneyPagesSummaryMetrics; /** * Render Money Pages Priority Table * @param {Array} moneyPages * @param {Object} filters - { typeFilter, minImpr, matrixFilter: {impact, diff} } */ /** * Sort money pages table rows * @param {Array} rows * @param {string} column - Column to sort by * @param {string} direction - 'asc' or 'desc' */ function sortMoneyPagesRows(rows, column, direction) { const priorityRank = { HIGH: 0, MEDIUM: 1, LOW: 2 }; const impactRank = { HIGH: 0, MEDIUM: 1, LOW: 2 }; const difficultyRank = { HIGH: 0, MEDIUM: 1, LOW: 2 }; const sorted = rows.slice().sort((a, b) => { let comparison = 0; switch (column) { case 'page': comparison = (a.title || a.url || '').localeCompare(b.title || b.url || ''); break; case 'type': const typeA = a.segmentType === "landing" ? "Landing" : a.segmentType === "event" ? "Event" : a.segmentType === "product" ? "Product" : "Money"; const typeB = b.segmentType === "landing" ? "Landing" : b.segmentType === "event" ? "Event" : b.segmentType === "product" ? "Product" : "Money"; comparison = typeA.localeCompare(typeB); break; case 'priority': const pa = priorityRank[a.priorityLevel] ?? 99; const pb = priorityRank[b.priorityLevel] ?? 99; comparison = pa - pb; break; case 'impact': const ia = impactRank[a.impactLevel] ?? 99; const ib = impactRank[b.impactLevel] ?? 99; comparison = ia - ib; break; case 'difficulty': const da = difficultyRank[a.difficultyLevel] ?? 99; const db = difficultyRank[b.difficultyLevel] ?? 99; comparison = da - db; break; case 'ctr': comparison = (a.ctr || 0) - (b.ctr || 0); break; case 'impressions': comparison = (a.impressions || 0) - (b.impressions || 0); break; case 'avgPosition': comparison = (a.avgPosition || 999) - (b.avgPosition || 999); break; case 'action': const actionA = (a.priorityLevel || "LOW") === "HIGH" ? "Fix CTR & strengthen content" : (a.priorityLevel || "LOW") === "MEDIUM" ? "Improve SERP snippet & schema" : "Monitor & maintain"; const actionB = (b.priorityLevel || "LOW") === "HIGH" ? "Fix CTR & strengthen content" : (b.priorityLevel || "LOW") === "MEDIUM" ? "Improve SERP snippet & schema" : "Monitor & maintain"; comparison = actionA.localeCompare(actionB); break; default: // Default: priority then lost clicks const paDefault = priorityRank[a.priorityLevel] ?? 99; const pbDefault = priorityRank[b.priorityLevel] ?? 99; if (paDefault !== pbDefault) { comparison = paDefault - pbDefault; } else { comparison = (b._lostClicks || 0) - (a._lostClicks || 0); } } return direction === 'asc' ? comparison : -comparison; }); return sorted; } // Initialize pagination state for Money Pages Priority table if (window.moneyPagesPriorityCurrentPage === undefined) { window.moneyPagesPriorityCurrentPage = 1; } if (window.moneyPagesPriorityRowsPerPage === undefined) { window.moneyPagesPriorityRowsPerPage = 10; } async function renderMoneyPagesPriorityTable(moneyPages, filters = {}) { const tbody = document.querySelector("#money-pages-priority-table tbody"); const thead = document.querySelector("#money-pages-priority-table thead"); if (!tbody || !thead) { debugLog('⚠ renderMoneyPagesPriorityTable: Table elements not found, table may not be rendered yet', 'warn'); // Return empty string instead of undefined to prevent error message return ''; } // Use window.moneyPagePriorityData if moneyPages is not provided or empty const sourcePages = (moneyPages && moneyPages.length > 0) ? moneyPages : (window.moneyPagePriorityData || []); if (!sourcePages || sourcePages.length === 0) { debugLog('⚠ renderMoneyPagesPriorityTable: No money pages data available', 'warn'); tbody.innerHTML = 'No money pages data available. Run an audit to generate priority matrix data.'; // Clear pagination if no data const paginationContainer = document.getElementById('money-pages-priority-pagination'); if (paginationContainer) { paginationContainer.innerHTML = ''; } return ''; } const typeFilter = filters.typeFilter || "all"; const minImpr = filters.minImpr || 0; const matrixFilter = filters.matrixFilter || null; let rows = sourcePages.slice(); // Min impressions filter should not hide domain-level authority actions. rows = rows.filter(p => p.segmentType === 'authority' || (p.impressions || 0) >= minImpr); if (typeFilter !== "all") { rows = rows.filter(p => p.segmentType === typeFilter); } // Matrix filter is only meaningful for money page rows; exclude authority rows unless explicitly filtering for them. if (matrixFilter && typeFilter !== "authority") { rows = rows.filter( p => p.segmentType !== 'authority' && p.impactLevel === matrixFilter.impact && p.difficultyLevel === matrixFilter.diff ); } // Apply sorting rows = sortMoneyPagesRows(rows, moneyPagesTableSort.column, moneyPagesTableSort.direction); // Store filtered rows for pagination and copy button window.moneyPagesPriorityFilteredRows = rows; // Update dropdown counts - use base data (after min impressions, before type filter) // Get base data for counting const base = window.moneyPagePriorityData || []; const authority = window.authorityActionRows || []; const allRows = base.concat(authority); // Apply min impressions filter for counting (but not type filter) const pagesForCounting = allRows.filter(p => p.segmentType === 'authority' || (p.impressions || 0) >= minImpr); updateMoneyPagesTypeFilterCounts(pagesForCounting); // Pagination logic const rowsPerPage = window.moneyPagesPriorityRowsPerPage || 10; const totalRows = rows.length; const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage)); let currentPage = window.moneyPagesPriorityCurrentPage || 1; // Ensure currentPage is within valid range if (currentPage > totalPages) { currentPage = totalPages; window.moneyPagesPriorityCurrentPage = currentPage; } if (currentPage < 1) { currentPage = 1; window.moneyPagesPriorityCurrentPage = currentPage; } // Slice rows for current page const startIndex = (currentPage - 1) * rowsPerPage; const endIndex = startIndex + rowsPerPage; const pageRows = rows.slice(startIndex, endIndex); // CRITICAL: DO NOT fetch statuses here - the caller should fetch before calling renderMoneyPagesPriorityTable // Fetching here causes the cache to be cleared every time the table renders, losing status information // Instead, we rely on the cache that was populated by the caller const allSourcePages = sourcePages || []; console.log('[Money Pages Priority] Rendering table using existing cache:', { cacheSize: window.optimisationStatusCache?.size || 0, sourcePagesCount: allSourcePages.length, filteredRowsCount: rows.length }); // Render sortable header with Ranking & AI styling const sortClass = (col) => { if (moneyPagesTableSort.column === col) { return moneyPagesTableSort.direction === 'asc' ? 'sort-asc' : 'sort-desc'; } return ''; }; thead.innerHTML = `
Keyword or Page
Type
Priority
Impact
Difficulty
CTR
Impr.
Avg pos.
AI citations
Action
Optimisation
`; // Add click handlers to header cells thead.querySelectorAll('th[data-sort]').forEach(th => { th.addEventListener('click', () => { const col = th.getAttribute('data-sort'); if (moneyPagesTableSort.column === col) { moneyPagesTableSort.direction = moneyPagesTableSort.direction === 'asc' ? 'desc' : 'asc'; } else { moneyPagesTableSort.column = col; moneyPagesTableSort.direction = 'asc'; } // Reset to page 1 when sorting changes window.moneyPagesPriorityCurrentPage = 1; (async () => { await renderMoneyPagesPriorityTable(moneyPages, filters); })(); }); th.addEventListener('mouseenter', () => { th.style.backgroundColor = '#e2e8f0'; }); th.addEventListener('mouseleave', () => { th.style.backgroundColor = ''; }); }); tbody.innerHTML = pageRows .map(p => { // Safety checks for undefined values const priorityLevel = p.priorityLevel || "LOW"; const impactLevel = p.impactLevel || "LOW"; const difficultyLevel = p.difficultyLevel || "MEDIUM"; const isAuthorityAction = p.segmentType === "authority"; const typeLabel = isAuthorityAction ? "Authority" : p.segmentType === "landing" ? "Landing" : p.segmentType === "event" ? "Event" : p.segmentType === "product" ? "Product" : "Money"; const action = isAuthorityAction ? "Authority building" : priorityLevel === "HIGH" ? "Fix CTR & strengthen content" : priorityLevel === "MEDIUM" ? "Improve SERP snippet & schema" : "Monitor & maintain"; const priorityColor = priorityLevel === "HIGH" ? "#ef4444" : priorityLevel === "MEDIUM" ? "#f59e0b" : "#64748b"; const priorityBg = priorityLevel === "HIGH" ? "#fee2e2" : priorityLevel === "MEDIUM" ? "#fef3c7" : "#f1f5f9"; const priorityBorder = priorityLevel === "HIGH" ? "#ef4444" : priorityLevel === "MEDIUM" ? "#f59e0b" : "#94a3b8"; const authMeta = isAuthorityAction ? (p._authorityMeta || null) : null; const authScore = authMeta && typeof authMeta.score === 'number' ? authMeta.score : null; const authBand = authMeta && typeof authMeta.band === 'string' ? authMeta.band : null; const authSegment = authMeta && typeof authMeta.segment === 'string' ? authMeta.segment : null; const domainStrengthLine = isAuthorityAction ? `Domain strength: ${authScore != null ? authScore.toFixed(1) : '—'}${authBand ? ` (${authBand})` : ''}` : null; // Get optimisation status for this page const taskType = 'on_page'; const optimisationStatus = window.getOptimisationStatus ? window.getOptimisationStatus({ targetUrl: p.url, keyword: '' }, taskType) : null; const hasTask = optimisationStatus && optimisationStatus.status && optimisationStatus.status !== 'deleted'; // Debug logging for landscape page if (p.url && p.url.includes('landscape-photography-workshops')) { const urlKey = window.cleanUrlForKey ? window.cleanUrlForKey(p.url) : p.url.toLowerCase().trim(); const expectedKey = `::${urlKey}::${taskType}`; const cacheValue = window.optimisationStatusCache?.get(expectedKey); debugLog(`[Money Pages Priority] Landscape page status lookup: url="${p.url}", urlKey="${urlKey}", expectedKey="${expectedKey}", found=${!!optimisationStatus}, status="${optimisationStatus?.status || 'null'}", id="${optimisationStatus?.id || 'null'}", cacheSize=${window.optimisationStatusCache?.size || 0}, cacheHasKey=${window.optimisationStatusCache?.has(expectedKey)}, cachedId="${cacheValue?.id || 'null'}", cachedStatus="${cacheValue?.status || 'null'}"`, optimisationStatus ? 'success' : 'warn'); } // CRITICAL: Use id field (not taskId) - API returns 'id', not 'taskId' const taskId = hasTask ? (optimisationStatus.id || optimisationStatus.task_id || null) : null; const statusText = hasTask ? (optimisationStatus.status || 'planned') : null; // Debug: Log status lookup for troubleshooting if (p.url && p.url.includes('landscape-photography-workshops')) { console.log('[Money Pages Priority] Status lookup for landscape page:', { url: p.url, hasStatus: !!optimisationStatus, hasId: optimisationStatus ? ('id' in optimisationStatus) : false, hasTaskId: optimisationStatus ? ('task_id' in optimisationStatus) : false, id: optimisationStatus?.id, task_id: optimisationStatus?.task_id, extractedTaskId: taskId, status: optimisationStatus?.status, statusObject: optimisationStatus ? { id: optimisationStatus.id, task_id: optimisationStatus.task_id, allKeys: Object.keys(optimisationStatus).slice(0, 15) } : null }); // Also log the onclick handler that will be generated if (taskId) { const taskIdEscaped = String(taskId).replace(/'/g, "\\'").replace(/"/g, '"'); const onclickHandler = `window.openOptimisationTaskDrawer('${taskIdEscaped}')`; console.log('[Money Pages Priority] Generated onclick handler:', { taskId: taskId, taskIdEscaped: taskIdEscaped, onclickHandler: onclickHandler }); } } // Validate taskId exists before rendering Manage button if (hasTask && !taskId) { console.error('[Money Pages Priority] Task exists but missing id field:', { url: p.url, optimisationStatus, statusKeys: optimisationStatus ? Object.keys(optimisationStatus) : [] }); } // Build optimisation column HTML let optimisationHtml = ''; if (!hasTask || statusText === 'done' || statusText === 'cancelled') { // Not tracked or done/cancelled - show Track button const statusLabel = !hasTask ? 'Not tracked' : (statusText === 'done' ? 'Done' : 'Cancelled'); optimisationHtml = ` ${statusLabel} `; } else { // Tracked - show status badge + Manage button const statusLabels = { 'planned': 'Planned', 'in_progress': 'In progress', 'monitoring': 'Monitoring', 'done': 'Done', 'cancelled': 'Cancelled' }; const statusColors = { 'planned': '#64748b', 'in_progress': '#2563eb', 'monitoring': '#10b981', 'done': '#10b981', 'cancelled': '#ef4444' }; const statusBg = { 'planned': '#f1f5f9', 'in_progress': '#dbeafe', 'monitoring': '#ecfdf5', 'done': '#ecfdf5', 'cancelled': '#fee2e2' }; const statusLabel = statusLabels[statusText] || statusText; const statusColor = statusColors[statusText] || '#64748b'; const statusBgColor = statusBg[statusText] || '#f1f5f9'; // Validate taskId before rendering Manage button if (!taskId) { console.error('[Money Pages Priority] Cannot render Manage button - taskId is missing:', { url: p.url, optimisationStatus }); optimisationHtml = ` Error: Task ID missing `; } else { // Escape taskId for use in onclick attribute const taskIdEscaped = String(taskId).replace(/'/g, "\\'").replace(/"/g, '"'); optimisationHtml = ` ${statusLabel} `; } } return ` ${p.title || p.url || 'Untitled'} ${domainStrengthLine ? `
${domainStrengthLine}
` : ''} ${typeLabel}${isAuthorityAction && authSegment ? ` · ${authSegment}` : ''} ${priorityLevel} ${impactLevel} ${difficultyLevel} ${isAuthorityAction ? '—' : (() => { const clicks = Number(p.clicks || 0); const imps = Number(p.impressions || 0); if (imps > 0) return `${((clicks / imps) * 100).toFixed(1)}%`; const raw = Number(p.ctr || 0); const ratio = raw > 1 ? raw / 100 : raw; return `${((ratio || 0) * 100).toFixed(1)}%`; })()} ${isAuthorityAction ? '—' : `${(p.impressions || 0).toLocaleString()}`} ${isAuthorityAction ? '—' : (p.avgPosition ? p.avgPosition.toFixed(1) : "—")} ⏳ ${action} ${optimisationHtml} `; }) .join(""); // Populate AI citations (latest audit) with per-URL caching (async () => { if (!window.moneyPagesAiCitationCache) { window.moneyPagesAiCitationCache = new Map(); } // Load from localStorage if available to prime cache try { const stored = localStorage.getItem('moneyPagesAiCitationCache'); if (stored) { const parsed = JSON.parse(stored); if (parsed && typeof parsed === 'object') { Object.entries(parsed).forEach(([k,v]) => { window.moneyPagesAiCitationCache.set(k, v); }); } } } catch (e) { debugLog(`[Money Pages] Failed to load cached citations: ${e.message}`, 'warn'); } const propertyUrl = document.getElementById('propertyUrl')?.value || localStorage.getItem('gsc_property_url') || localStorage.getItem('last_property_url') || 'https://www.alanranger.com'; const renderCitation = (cell, count, keywords, errorText = null) => { cell.textContent = typeof count === 'number' ? count.toString() : '—'; if (keywords && keywords.length) { cell.title = `Keywords citing this URL: ${keywords.join(', ')}`; } else if (errorText) { cell.title = errorText; } // Write back to underlying row for sorting const url = cell.getAttribute('data-page-url'); if (url && window.moneyPagesData) { const match = window.moneyPagesData.find(r => (r.url || '').toLowerCase() === url.toLowerCase()); if (match) { match._aiCitations = typeof count === 'number' ? count : null; } } }; const cells = tbody.querySelectorAll('.ai-citation-cell'); cells.forEach(cell => { const url = cell.getAttribute('data-page-url'); if (!url) { cell.textContent = '—'; return; } const cacheKey = url.toLowerCase(); if (window.moneyPagesAiCitationCache.has(cacheKey)) { const cached = window.moneyPagesAiCitationCache.get(cacheKey); renderCitation(cell, cached.count, cached.keywords); return; } cell.textContent = '⏳'; const endpoint = apiUrl( `/api/supabase/query-keywords-citing-url?property_url=${encodeURIComponent(propertyUrl)}&target_url=${encodeURIComponent(url)}` ); fetch(endpoint) .then(async res => { if (!res.ok) { const txt = await res.text().catch(() => 'error'); throw new Error(`HTTP ${res.status}: ${txt}`); } return res.json(); }) .then(data => { const keywords = Array.isArray(data?.data) ? data.data.map(k => k.keyword).filter(Boolean) : []; const count = typeof data?.count === 'number' ? data.count : keywords.length; window.moneyPagesAiCitationCache.set(cacheKey, { count, keywords }); try { const obj = Object.fromEntries(window.moneyPagesAiCitationCache.entries()); localStorage.setItem('moneyPagesAiCitationCache', JSON.stringify(obj)); } catch (e) { debugLog(`[Money Pages] Failed to persist citations cache: ${e.message}`, 'warn'); } renderCitation(cell, count, keywords); }) .catch(err => { debugLog(`[Money Pages] AI citations fetch failed for ${url}: ${err.message}`, 'warn'); renderCitation(cell, 0, [], err.message); }); }); })(); // Copy URLs button - copy all filtered rows, not just current page const copyBtn = document.getElementById("money-pages-copy-urls-btn"); if (copyBtn) { copyBtn.onclick = () => { const urls = rows.map(p => p.url).join("\n"); navigator.clipboard.writeText(urls).then(() => { copyBtn.textContent = "Copied!"; setTimeout(() => { copyBtn.textContent = "Copy URLs"; }, 2000); }).catch(() => { copyBtn.textContent = "Copy failed"; }); }; } // Wire Track/Manage buttons after table is rendered // NOTE: The new renderMoneyPagesTable function uses onclick attributes directly // This old handler is only for legacy table rendering that uses .money-pages-track-btn class // If you see "Old handler detected" warnings, it means there's a button with the old class // that should be using onclick="window.trackMoneyPage(...)" instead setTimeout(() => { // Track buttons (legacy - should not be used in new table rendering) tbody.querySelectorAll('.money-pages-track-btn').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); e.preventDefault(); // Prevent any default behavior if (window.isShareMode) { alert('Write operations are not available in share mode (read-only).'); return; } if (!window.hasAdminKey()) { alert('Admin key required. Please set your admin key in the "Optimisation Tracking Security" section.'); return; } const pageUrl = btn.getAttribute('data-page-url'); const pageTitle = btn.getAttribute('data-page-title'); console.warn('[Money Pages] ⚠️ Legacy .money-pages-track-btn handler triggered. This should not happen with new table rendering.'); debugLog(`⚠ Legacy handler detected for ${pageUrl}, redirecting to modal`, 'warn'); // Immediately redirect to the new modal path // Look up row data from stored map let rowData = null; if (window.moneyPagesRowDataByUrl && pageUrl) { const normalizedUrl = normalizeUrlForDedupe ? normalizeUrlForDedupe(pageUrl) : pageUrl.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, ''); rowData = window.moneyPagesRowDataByUrl.get(normalizedUrl); console.log('[Money Pages] Legacy handler: Found row data from map:', !!rowData); } // If row data not found, create minimal fallback if (!rowData) { rowData = { url: pageUrl, title: pageTitle, clicks: 0, impressions: 0, ctr: 0, avgPosition: null }; console.warn('[Money Pages] Legacy handler: Row data not found, using fallback (metrics will be 0)'); } // Use the new modal path if (window.openTrackMoneyPageModal) { window.openTrackMoneyPageModal(rowData); } else if (window.trackMoneyPage) { window.trackMoneyPage(pageUrl, pageTitle); } else { alert('Error: Modal function not found. Please refresh the page.'); } }); }); // Manage buttons tbody.querySelectorAll('.money-pages-manage-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const taskId = btn.getAttribute('data-task-id'); if (!taskId) { alert('Task ID not found'); return; } // Open task drawer if (window.openOptimisationTaskDrawer && typeof window.openOptimisationTaskDrawer === 'function') { window.openOptimisationTaskDrawer(taskId); } else { // Fallback: navigate to Optimisation Tracking const optimisationTab = document.querySelector('[data-panel="optimisation"]'); if (optimisationTab) { const tabBtn = document.querySelector(`button[data-tab="optimisation"]`); if (tabBtn) tabBtn.click(); setTimeout(() => { if (window.openOptimisationTaskDrawer && typeof window.openOptimisationTaskDrawer === 'function') { window.openOptimisationTaskDrawer(taskId); } }, 500); } } }); }); }, 100); // Render pagination controls renderMoneyPagesPriorityPagination(totalRows, currentPage, totalPages, rowsPerPage); // Return empty string since this function manipulates DOM directly return ''; } function renderMoneyPagesPriorityPagination(totalRows, currentPage, totalPages, rowsPerPage) { const paginationContainer = document.getElementById('money-pages-priority-pagination'); if (!paginationContainer) { debugLog('⚠ Pagination container not found', 'warn'); return; } if (totalRows === 0) { paginationContainer.innerHTML = ''; return; } const startRow = totalRows === 0 ? 0 : (currentPage - 1) * rowsPerPage + 1; const endRow = Math.min(currentPage * rowsPerPage, totalRows); paginationContainer.innerHTML = `
Showing ${startRow}-${endRow} of ${totalRows.toLocaleString()}
Page ${currentPage} of ${totalPages}
`; // Wire up pagination buttons const firstBtn = document.getElementById('money-pages-priority-pagination-first'); const prevBtn = document.getElementById('money-pages-priority-pagination-prev'); const nextBtn = document.getElementById('money-pages-priority-pagination-next'); const lastBtn = document.getElementById('money-pages-priority-pagination-last'); const rowsPerPageSelect = document.getElementById('money-pages-priority-rows-per-page'); if (firstBtn) { firstBtn.addEventListener('click', () => { window.moneyPagesPriorityCurrentPage = 1; const filters = { typeFilter: window.moneyPagesTypeFilter || 'all', minImpr: window.moneyPagesMinImpr || 0, matrixFilter: window.moneyPagesMatrixFilter || null }; (async () => { await renderMoneyPagesPriorityTable(null, filters); })(); }); } if (prevBtn) { prevBtn.addEventListener('click', () => { if (window.moneyPagesPriorityCurrentPage > 1) { window.moneyPagesPriorityCurrentPage--; const filters = { typeFilter: window.moneyPagesTypeFilter || 'all', minImpr: window.moneyPagesMinImpr || 0, matrixFilter: window.moneyPagesMatrixFilter || null }; (async () => { await renderMoneyPagesPriorityTable(null, filters); })(); } }); } if (nextBtn) { nextBtn.addEventListener('click', () => { const totalRows = window.moneyPagesPriorityFilteredRows?.length || 0; const rowsPerPage = window.moneyPagesPriorityRowsPerPage || 10; const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage)); if (window.moneyPagesPriorityCurrentPage < totalPages) { window.moneyPagesPriorityCurrentPage++; const filters = { typeFilter: window.moneyPagesTypeFilter || 'all', minImpr: window.moneyPagesMinImpr || 0, matrixFilter: window.moneyPagesMatrixFilter || null }; (async () => { await renderMoneyPagesPriorityTable(null, filters); })(); } }); } if (lastBtn) { lastBtn.addEventListener('click', () => { const totalRows = window.moneyPagesPriorityFilteredRows?.length || 0; const rowsPerPage = window.moneyPagesPriorityRowsPerPage || 10; const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage)); window.moneyPagesPriorityCurrentPage = totalPages; const filters = { typeFilter: window.moneyPagesTypeFilter || 'all', minImpr: window.moneyPagesMinImpr || 0, matrixFilter: window.moneyPagesMatrixFilter || null }; (async () => { await renderMoneyPagesPriorityTable(null, filters); })(); }); } if (rowsPerPageSelect) { rowsPerPageSelect.addEventListener('change', (e) => { const newRowsPerPage = parseInt(e.target.value, 10); window.moneyPagesPriorityRowsPerPage = newRowsPerPage; window.moneyPagesPriorityCurrentPage = 1; // Reset to first page const filters = { typeFilter: window.moneyPagesTypeFilter || 'all', minImpr: window.moneyPagesMinImpr || 0, matrixFilter: window.moneyPagesMatrixFilter || null }; (async () => { await renderMoneyPagesPriorityTable(null, filters); })(); }); } } // Make Priority table renderer globally available (avoid clobbering Opportunity table renderer) window.renderMoneyPagesPriorityTable = renderMoneyPagesPriorityTable; function applyMoneyPagesTopLevelSubSegmentFilter(value) { const subSeg = value || 'ALL'; debugLog(`🔄 Top-level filter changed to: ${subSeg}`, 'info'); window.moneyPagesSubSegmentFilter = subSeg; // Keep table filter in sync (if present) const tableSubSegFilter = document.getElementById('money-pages-filter-subsegment'); if (tableSubSegFilter && tableSubSegFilter.value !== subSeg) { tableSubSegFilter.value = subSeg; } const moneyPagesMetrics = window.currentMoneyPagesMetrics || window.moneyPagesMetrics; if (!moneyPagesMetrics) { debugLog('⚠ Top-level filter: No moneyPagesMetrics available', 'warn'); return; } if (typeof updateMoneyPagesSummaryMetrics === 'function') { updateMoneyPagesSummaryMetrics(moneyPagesMetrics); } const filteredMetrics = typeof getFilteredMoneyPagesMetrics === 'function' ? getFilteredMoneyPagesMetrics(moneyPagesMetrics) : null; const filteredRows = filteredMetrics?.rows || []; const queryPages = window.currentQueryPages || null; // Update dropdown counts (both table and top-level filters) updateMoneyPagesSubSegmentCounts(moneyPagesMetrics); // Also update top-level filter dropdown counts const topLevelFilterEl = document.getElementById('money-top-level-filter-subsegment'); if (topLevelFilterEl && moneyPagesMetrics && moneyPagesMetrics.rows) { const rows = moneyPagesMetrics.rows || []; const counts = { ALL: rows.length, PRODUCT: 0, EVENT: 0, LANDING: 0 }; rows.forEach(row => { const subSegment = row.subSegment || row.segmentType || 'LANDING'; if (subSegment === 'PRODUCT' || subSegment === 'product') { counts.PRODUCT++; } else if (subSegment === 'EVENT' || subSegment === 'event') { counts.EVENT++; } else if (subSegment === 'LANDING' || subSegment === 'landing') { counts.LANDING++; } }); // Update top-level filter options const options = topLevelFilterEl.querySelectorAll('option'); options.forEach(opt => { const value = opt.value; if (value === 'ALL') { opt.textContent = `All sub-segments (${counts.ALL})`; } else if (value === 'PRODUCT') { opt.textContent = `Product Pages (${counts.PRODUCT})`; } else if (value === 'EVENT') { opt.textContent = `Event Pages (${counts.EVENT})`; } else if (value === 'LANDING') { opt.textContent = `Landing Pages (${counts.LANDING})`; } }); } if (typeof renderMoneyPagesBehaviourKpis === 'function') { const behaviour = (queryPages && typeof window.computeMoneyPagesBehaviour === 'function') ? window.computeMoneyPagesBehaviour(queryPages, filteredRows, true) : null; renderMoneyPagesBehaviourKpis(behaviour, filteredMetrics || moneyPagesMetrics, queryPages); } if (typeof updateMoneyPagesChartSummary === 'function' && filteredMetrics) { updateMoneyPagesChartSummary(filteredMetrics); } if (typeof renderMoneyPagesCategoryChart === 'function') { renderMoneyPagesCategoryChart(moneyPagesMetrics, 0); } // Re-render Performance Trends to reflect the selected sub-segment (ALL/LANDING/EVENT/PRODUCT). // Uses cached timeseries loaded by the KPI tracker. if (typeof window.renderMoneyPagesTrendChart === 'function') { const ts = window.__moneyPagesTimeseriesCache; if (Array.isArray(ts) && ts.length > 0) { window.renderMoneyPagesTrendChart([], ts); } } // Re-render Suggested Top 10 to reflect the selected sub-segment. if (typeof window.renderMoneyPagesSuggestedTop10 === 'function') { window.renderMoneyPagesSuggestedTop10(); } } // Wire up top-level filter (independent of table section) function wireTopLevelFilter(retryCount = 0) { setTimeout(() => { const topLevelFilterEl = document.getElementById('money-top-level-filter-subsegment'); if (!topLevelFilterEl) { debugLog(`⚠ Top-level filter not found (may not be rendered yet) (attempt ${retryCount + 1})`, 'warn'); if (retryCount < 10) { setTimeout(() => wireTopLevelFilter(retryCount + 1), 200); } return; } // Remove existing listener by cloning const newFilter = topLevelFilterEl.cloneNode(true); topLevelFilterEl.parentNode.replaceChild(newFilter, topLevelFilterEl); // Initialize to match global state const currentSubSeg = window.moneyPagesSubSegmentFilter || 'ALL'; newFilter.value = currentSubSeg; // Update counts when first wiring up const moneyPagesMetrics = window.currentMoneyPagesMetrics || window.moneyPagesMetrics; if (moneyPagesMetrics && moneyPagesMetrics.rows) { const rows = moneyPagesMetrics.rows || []; const counts = { ALL: rows.length, PRODUCT: 0, EVENT: 0, LANDING: 0 }; rows.forEach(row => { const subSegment = row.subSegment || row.segmentType || 'LANDING'; if (subSegment === 'PRODUCT' || subSegment === 'product') { counts.PRODUCT++; } else if (subSegment === 'EVENT' || subSegment === 'event') { counts.EVENT++; } else if (subSegment === 'LANDING' || subSegment === 'landing') { counts.LANDING++; } }); // Update top-level filter options with counts const options = newFilter.querySelectorAll('option'); options.forEach(opt => { const value = opt.value; if (value === 'ALL') { opt.textContent = `All sub-segments (${counts.ALL})`; } else if (value === 'PRODUCT') { opt.textContent = `Product Pages (${counts.PRODUCT})`; } else if (value === 'EVENT') { opt.textContent = `Event Pages (${counts.EVENT})`; } else if (value === 'LANDING') { opt.textContent = `Landing Pages (${counts.LANDING})`; } }); } debugLog(`✓ Top-level filter found and initialized to: ${currentSubSeg}`, 'success'); // When top-level filter changes, update all sections newFilter.addEventListener('change', () => applyMoneyPagesTopLevelSubSegmentFilter(newFilter.value)); // CRITICAL: Apply the current filter once on initial wire-up so Behaviour KPIs / summary // don't stay as placeholder "—" until the user changes the dropdown. if (typeof applyMoneyPagesTopLevelSubSegmentFilter === 'function') { applyMoneyPagesTopLevelSubSegmentFilter(currentSubSeg); } debugLog('✓ Top-level filter wired up successfully', 'success'); }, 200); } // Make function globally available window.wireTopLevelFilter = wireTopLevelFilter; // Suggested (Top 10) cards renderer // Renders from `window.moneyPagePriorityData` (built during score calculation / loaded from Supabase). window.renderMoneyPagesSuggestedTop10 = function renderMoneyPagesSuggestedTop10() { const container = document.getElementById('money-pages-suggested-top10-container'); if (!container) return; // Source of truth for Suggested: align to Opportunity table's CURRENT filtered rows. const tableRows = Array.isArray(window.moneyPagesOpportunityFilteredRows) ? window.moneyPagesOpportunityFilteredRows : []; const priorityRows = Array.isArray(window.moneyPagePriorityData) ? window.moneyPagePriorityData : []; const norm = (u) => normalizeUrlForDedupe ? normalizeUrlForDedupe(u) : String(u || '').toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, ''); const priorityByUrl = new Map(priorityRows.filter(p => p && p.url).map(p => [norm(p.url), p])); const candidates = (tableRows && tableRows.length > 0) ? tableRows .filter(r => r && r.url) .map(r => { const p = priorityByUrl.get(norm(r.url)) || null; const segmentType = (p?.segmentType || r.subSegment || r.segmentType || 'landing'); // If priority data exists, use it. Otherwise derive basic levels. const derivedDifficulty = (() => { const pos = Number(r.avgPosition || 0); if (pos <= 6) return 'LOW'; if (pos <= 12) return 'MEDIUM'; return 'HIGH'; })(); const derivedPriority = (() => { // Use category from Opportunity table if available. const cat = String(r.category || '').toUpperCase(); if (cat === 'HIGH_OPPORTUNITY') return 'HIGH'; if (cat === 'VISIBILITY_FIX') return 'MEDIUM'; return 'LOW'; })(); return { url: r.url, title: r.title || p?.title || r.url, segmentType: String(segmentType).toLowerCase(), impressions: Number(r.impressions || 0), clicks: Number(r.clicks || 0), ctr: r.ctr != null ? Number(r.ctr) : null, avgPosition: r.avgPosition != null ? Number(r.avgPosition) : null, // Levels used by the card renderer difficultyLevel: (p?.difficultyLevel || derivedDifficulty), priorityLevel: (p?.priorityLevel || derivedPriority), impactLevel: (p?.impactLevel || 'MEDIUM'), suggestion: (p?.suggestion || r.recommendedAction || r.opportunityText || null) }; }) : priorityRows .filter(p => p && p.url && p.segmentType !== 'authority'); if (candidates.length === 0) { container.innerHTML = '

No suggested pages for this filter.

'; return; } const rank = (v, map) => map[String(v || '').toUpperCase()] ?? 99; const priorityRank = { HIGH: 0, MEDIUM: 1, LOW: 2 }; const impactRank = { HIGH: 0, MEDIUM: 1, LOW: 2 }; const diffRank = { LOW: 0, MEDIUM: 1, HIGH: 2 }; // Rank Suggested primarily by impressions (matches Opportunity table expectation), // with secondary tie-breaks for priority/impact/difficulty when available. const top10 = candidates .slice() .sort((a, b) => { const ia = Number(a.impressions || 0); const ib = Number(b.impressions || 0); if (ib !== ia) return ib - ia; const pa = rank(a.priorityLevel, priorityRank); const pb = rank(b.priorityLevel, priorityRank); if (pa !== pb) return pa - pb; const impa = rank(a.impactLevel, impactRank); const impb = rank(b.impactLevel, impactRank); if (impa !== impb) return impa - impb; const da = rank(a.difficultyLevel, diffRank); const db = rank(b.difficultyLevel, diffRank); if (da !== db) return da - db; return Number(b.clicks || 0) - Number(a.clicks || 0); }) .slice(0, 10); const TARGET_CTR = 0.025; // 2.5% target used by Money Pages optimisation tasks const ctrPct = (p) => { const clicks = Number(p.clicks || 0); const imps = Number(p.impressions || 0); if (imps > 0) return (clicks / imps) * 100; const raw = Number(p.ctr || 0); const ratio = raw > 1 ? raw / 100 : raw; return (ratio || 0) * 100; }; const ctrRatio = (p) => { const clicks = Number(p.clicks || 0); const imps = Number(p.impressions || 0); if (imps > 0) return clicks / imps; const raw = Number(p.ctr || 0); const ratio = raw > 1 ? raw / 100 : raw; return ratio || 0; }; const expectedIncreaseClicks28dToTargetCtr = (p) => { const imps = Number(p.impressions || 0); if (!(imps > 0)) return 0; const gap = Math.max(0, TARGET_CTR - ctrRatio(p)); return Math.round(imps * gap); }; const maxExpectedUp = Math.max(1, ...top10.map(p => expectedIncreaseClicks28dToTargetCtr(p) || 0)); const impactScore100 = (p) => { const up = expectedIncreaseClicks28dToTargetCtr(p) || 0; return Math.max(0, Math.min(100, Math.round((up / maxExpectedUp) * 100))); }; container.innerHTML = top10.map((p) => { const status = window.getOptimisationStatus ? window.getOptimisationStatus({ targetUrl: p.url, keyword: '' }, 'on_page') : null; const statusText = status?.status || null; const hasTask = !!statusText && statusText !== 'deleted' && statusText !== 'done' && statusText !== 'cancelled'; const taskId = hasTask ? (status.id || status.task_id || null) : null; const pri = String(p.priorityLevel || '').toUpperCase() || 'MED'; const diff = String(p.difficultyLevel || '').toUpperCase() || 'MEDIUM'; const imp = String(p.impactLevel || '').toUpperCase() || 'MEDIUM'; const diffColors = { LOW: '#10b981', MEDIUM: '#f59e0b', HIGH: '#ef4444' }; const diffColor = diffColors[diff] || '#64748b'; const priColors = { LOW: '#10b981', MEDIUM: '#f59e0b', HIGH: '#ef4444' }; const priColor = priColors[pri] || '#64748b'; const title = p.title || p.url; const safeUrl = String(p.url).replace(/'/g, "\\'"); const safeTitle = String(title).replace(/'/g, "\\'"); const expectedUp = expectedIncreaseClicks28dToTargetCtr(p); const impact100 = impactScore100(p); const typeTag = String(p.segmentType || 'landing').toLowerCase(); const typeColors = { landing: { bg: '#dbeafe', fg: '#1d4ed8' }, event: { bg: '#fee2e2', fg: '#b91c1c' }, product: { bg: '#d1fae5', fg: '#065f46' } }; const typeStyle = typeColors[typeTag] || { bg: '#e2e8f0', fg: '#334155' }; return `
${hasTask ? `
✓ Being Optimised
` : ''}
${escapeHtml(title)}
${p.url}
${escapeHtml(typeTag)}
${pri}
Pos: ${Number(p.avgPosition || 0).toFixed(1)}
CTR: ${ctrPct(p).toFixed(1)}%
Impr: ${Number(p.impressions || 0).toLocaleString()}
Upside: ${Number(expectedUp || 0).toLocaleString()}
Impact: ${impact100}
Difficulty: ${diff}
AI cites:
Potential impact clicks 28d: ${Number(expectedUp || 0).toLocaleString()}
${escapeHtml(p.suggestion || 'Improve relevance + internal links to lift rank')}
`; }).join(''); }; // Wire up Priority & Actions filters function wirePriorityActionsFilters() { setTimeout(() => { const typeFilterEl = document.getElementById('money-pages-type-filter'); const minImprEl = document.getElementById('money-pages-min-impr'); const matrixEl = document.getElementById('money-pages-matrix'); if (!typeFilterEl || !minImprEl) { debugLog('⚠ Priority & Actions filters not found (may not be rendered yet)', 'warn'); return; } debugLog('✓ Priority & Actions filter elements found', 'success'); // Remove existing listeners by cloning elements const newTypeFilter = typeFilterEl.cloneNode(true); const newMinImpr = minImprEl.cloneNode(true); typeFilterEl.parentNode.replaceChild(newTypeFilter, typeFilterEl); minImprEl.parentNode.replaceChild(newMinImpr, minImprEl); // Update counts initially when wiring up (apply min impressions filter if set) const base = window.moneyPagePriorityData || []; const authority = window.authorityActionRows || []; const allRows = base.concat(authority); if (allRows && allRows.length > 0) { // Get current min impressions value const initialMinImpr = parseInt(newMinImpr.value, 10) || 0; // Apply min impressions filter for counting const pagesForCounting = allRows.filter(p => p.segmentType === 'authority' || (p.impressions || 0) >= initialMinImpr); updateMoneyPagesTypeFilterCounts(pagesForCounting); } // Function to update matrix and table when filters change const updatePriorityActions = () => { const typeFilter = newTypeFilter.value || 'all'; const minImpr = parseInt(newMinImpr.value, 10) || 0; // Store filter state globally window.moneyPagesTypeFilter = typeFilter; window.moneyPagesMinImpr = minImpr; debugLog(`🔄 Priority & Actions filter changed: type=${typeFilter}, minImpr=${minImpr}`, 'info'); // Get current data const base = window.moneyPagePriorityData || []; const authority = window.authorityActionRows || []; const allRows = base.concat(authority); if (!allRows || allRows.length === 0) { debugLog('⚠ No moneyPagePriorityData available for Priority & Actions', 'warn'); return; } debugLog(`✓ Priority & Actions: Found ${allRows.length} rows (money + authority)`, 'success'); // Apply min impressions filter first (for counting purposes) let pagesForCounting = allRows.filter(p => p.segmentType === 'authority' || (p.impressions || 0) >= minImpr); // Update dropdown counts with filtered data (after min impressions filter) updateMoneyPagesTypeFilterCounts(pagesForCounting); // Apply type filter to get final filtered pages let filteredPages = pagesForCounting; if (typeFilter !== 'all') { filteredPages = filteredPages.filter(p => p.segmentType === typeFilter); } debugLog(`✓ Priority & Actions: Filtered to ${filteredPages.length} pages`, 'success'); // Re-render matrix with filtered data if (matrixEl && typeof renderMoneyPagesMatrix === 'function') { const pagesOnly = filteredPages.filter(p => p.segmentType !== 'authority'); const activeFilter = window.moneyMatrixFilterState?.impact && window.moneyMatrixFilterState?.diff ? { impact: window.moneyMatrixFilterState.impact, diff: window.moneyMatrixFilterState.diff } : null; const handleMatrixCellClick = (impact, diff) => { // Toggle filter: if clicking the same cell, clear the filter const currentFilter = window.moneyMatrixFilterState; if (currentFilter && currentFilter.impact === impact && currentFilter.diff === diff) { // Same cell clicked - clear filter window.moneyMatrixFilterState = null; } else { // Different cell clicked - set new filter window.moneyMatrixFilterState = { impact, diff }; } // Re-render matrix with updated active filter const newActiveFilter = window.moneyMatrixFilterState && window.moneyMatrixFilterState.impact && window.moneyMatrixFilterState.diff ? { impact: window.moneyMatrixFilterState.impact, diff: window.moneyMatrixFilterState.diff } : null; renderMoneyPagesMatrix(pagesOnly, matrixEl, handleMatrixCellClick, newActiveFilter); // Re-render table with matrix filter (or no filter if cleared) const filters = { typeFilter, minImpr, matrixFilter: newActiveFilter }; if (typeof window.renderMoneyPagesPriorityTable === 'function') { (async () => { // Render the Priority & Actions table (not the opportunity table) await window.renderMoneyPagesPriorityTable(filteredPages, filters); })(); } }; renderMoneyPagesMatrix(pagesOnly, matrixEl, handleMatrixCellClick, activeFilter); } else { debugLog('⚠ renderMoneyPagesMatrix function or matrix element not found', 'warn'); } // Re-render table with filters if (typeof window.renderMoneyPagesPriorityTable === 'function') { const filters = { typeFilter, minImpr, matrixFilter: window.moneyMatrixFilterState?.impact && window.moneyMatrixFilterState?.diff ? { impact: window.moneyMatrixFilterState.impact, diff: window.moneyMatrixFilterState.diff } : null }; (async () => { await window.renderMoneyPagesPriorityTable(filteredPages, filters); })(); } else { debugLog('⚠ renderMoneyPagesPriorityTable function not found', 'warn'); } }; // Attach event listeners newTypeFilter.addEventListener('change', updatePriorityActions); newMinImpr.addEventListener('input', updatePriorityActions); debugLog('✓ Priority & Actions filters wired up', 'success'); }, 200); } // Make function globally available window.wirePriorityActionsFilters = wirePriorityActionsFilters; // ============================================================================ // 12-Month KPI Tracker Functions // ============================================================================ let moneyKpiChart = null; let cachedAuditHistory = null; // Sort state for tables let moneyPagesTableSort = { column: 'priority', direction: 'asc' }; // 'asc' or 'desc' let moneyKpiTableSort = { column: 'segment', direction: 'asc' }; // 'asc' or 'desc' /** * Build KPI history structure from audit history * @param {Array<{date: string, moneySegmentMetrics: MoneySegmentMetricsByAudit | null}>} history * @param {string} metricKey - 'ctr', 'impressions', 'clicks', or 'avgPosition' * @returns {{ labels: string[], segments: Record }} */ function buildMoneyKpiHistory(history, metricKey, timeseries = [], latestMoneySegmentMetrics = null, latestMoneyPagePriorityData = []) { // Use last 28 days (matching Performance Trends charts) with 8 weekly date points (28 days / 4 = 7 weeks) // Get the last GSC timeseries date from actual timeseries data let lastGscTimeseriesDate = null; if (timeseries.length > 0) { const timeseriesDates = timeseries.map(ts => ts.date).filter(Boolean).sort().reverse(); if (timeseriesDates.length > 0) { lastGscTimeseriesDate = timeseriesDates[0]; } } // Fallback to window value if timeseries not provided if (!lastGscTimeseriesDate) { lastGscTimeseriesDate = window.lastGscTimeseriesDate || null; } // Calculate 28-day range ending at last GSC date (inclusive of end date, so go back 27 days) const endDate = lastGscTimeseriesDate ? new Date(lastGscTimeseriesDate + 'T00:00:00') : new Date(); const startDate = new Date(endDate); startDate.setDate(startDate.getDate() - 27); // 27 days back + end date = 28 days total startDate.setHours(0, 0, 0, 0); endDate.setHours(0, 0, 0, 0); // Generate 8 weekly date points across the 28-day range (28 days / 4 = 7 weeks, so 8 points) // Range is from startDate to endDate (inclusive) = 28 days total const datePoints = []; const totalDays = 28; // startDate to endDate inclusive const numPoints = 8; // Weekly points: 7 intervals of 4 days each const step = (totalDays - 1) / (numPoints - 1); // 8 points = 7 intervals across 28 days for (let i = 0; i < numPoints; i++) { const date = new Date(startDate); date.setDate(date.getDate() + Math.round(i * step)); date.setHours(0, 0, 0, 0); const dateStr = date.toISOString().split('T')[0]; datePoints.push(dateStr); } // Ensure last point is exactly endDate if (datePoints.length > 0) { datePoints[datePoints.length - 1] = endDate.toISOString().split('T')[0]; } // Build rolling 28-day site totals from daily GSC timeseries. // This avoids mixing 28d metrics (moneySegmentMetrics) with 1-day metrics (gsc_timeseries). const tsSorted = (Array.isArray(timeseries) ? timeseries : []) .filter(ts => ts && ts.date) .slice() .sort((a, b) => String(a.date).localeCompare(String(b.date))); const rolling28d = (endDateStr) => { if (!endDateStr || tsSorted.length === 0) return { clicks: 0, impressions: 0, days: 0, posWeight: 0, posImpr: 0 }; const end = new Date(endDateStr + 'T00:00:00'); if (Number.isNaN(end.getTime())) return { clicks: 0, impressions: 0, days: 0, posWeight: 0, posImpr: 0 }; const start = new Date(end); start.setDate(start.getDate() - 27); const startStr = start.toISOString().split('T')[0]; let clicks = 0; let impressions = 0; let days = 0; let posWeight = 0; let posImpr = 0; for (const ts of tsSorted) { const d = String(ts.date); if (d < startStr) continue; if (d > endDateStr) break; clicks += Number(ts.clicks || 0); impressions += Number(ts.impressions || 0); days += 1; const impr = Number(ts.impressions || 0); const pos = Number(ts.position); if (impr > 0 && !Number.isNaN(pos) && isFinite(pos) && pos > 0) { posWeight += pos * impr; posImpr += impr; } } return { clicks, impressions, days, posWeight, posImpr }; }; const clamp01 = (x) => Math.max(0, Math.min(1, x)); // Prefer moneySegmentMetrics if present; otherwise fall back to the latest Money Pages overview. // (Some audits may not have `moneySegmentMetrics` persisted, but we still have moneyClicks/impressions.) const overview = window.moneyPagesMetrics?.overview || null; const refAllMoney = latestMoneySegmentMetrics?.allMoney || (history.slice().reverse().find(r => r && r.moneySegmentMetrics)?.moneySegmentMetrics?.allMoney) || window.moneySegmentMetrics?.allMoney || (overview ? { clicks: Number(overview.moneyClicks || 0), impressions: Number(overview.moneyImpressions || 0), avgPosition: (typeof overview.moneyAvgPosition === 'number' ? overview.moneyAvgPosition : null), behaviourScore: (typeof window.moneyPagesMetrics?.behaviour?.score === 'number' ? window.moneyPagesMetrics.behaviour.score : null) } : null); const siteRollAtEnd = rolling28d(endDate.toISOString().split('T')[0]); const moneyClicks28d = Number(refAllMoney?.clicks || overview?.moneyClicks || 0); const moneyImpressions28d = Number(refAllMoney?.impressions || overview?.moneyImpressions || 0); const moneyClicksShare = siteRollAtEnd.clicks > 0 && moneyClicks28d > 0 ? clamp01(moneyClicks28d / siteRollAtEnd.clicks) : 0; const moneyImpressionsShare = siteRollAtEnd.impressions > 0 && moneyImpressions28d > 0 ? clamp01(moneyImpressions28d / siteRollAtEnd.impressions) : 0; // Segment split within money pages (Landing/Event/Product) – use any available segment metrics. const segSource = latestMoneySegmentMetrics || (history.slice().reverse().find(r => r && r.moneySegmentMetrics)?.moneySegmentMetrics) || window.moneySegmentMetrics || null; const segmentProportions = { landingPages: { clicks: 0, impressions: 0 }, eventPages: { clicks: 0, impressions: 0 }, productPages: { clicks: 0, impressions: 0 } }; if (segSource && segSource.allMoney) { const allClicks = Number(segSource.allMoney.clicks || moneyClicks28d || 0); const allImps = Number(segSource.allMoney.impressions || moneyImpressions28d || 0); if (allClicks > 0) { segmentProportions.landingPages.clicks = Number(segSource.landingPages?.clicks || 0) / allClicks; segmentProportions.eventPages.clicks = Number(segSource.eventPages?.clicks || 0) / allClicks; segmentProportions.productPages.clicks = Number(segSource.productPages?.clicks || 0) / allClicks; } if (allImps > 0) { segmentProportions.landingPages.impressions = Number(segSource.landingPages?.impressions || 0) / allImps; segmentProportions.eventPages.impressions = Number(segSource.eventPages?.impressions || 0) / allImps; segmentProportions.productPages.impressions = Number(segSource.productPages?.impressions || 0) / allImps; } } debugLog( `[KPI Tracker] Rolling-28d shares: clicksShare=${(moneyClicksShare * 100).toFixed(2)}%, imprShare=${(moneyImpressionsShare * 100).toFixed(2)}% (siteRollAtEnd clicks=${siteRollAtEnd.clicks}, imps=${siteRollAtEnd.impressions})`, 'info' ); // Create labels using same format as Performance Trends charts (e.g., "17 Nov", "15 Dec") const labels = datePoints.map(dateStr => { const date = new Date(dateStr + 'T00:00:00'); return date.toLocaleDateString('en-GB', { month: 'short', day: 'numeric' }); }); const segments = { allMoney: [], landingPages: [], eventPages: [], productPages: [] }; const refAvgPos = (typeof refAllMoney?.avgPosition === 'number' ? refAllMoney.avgPosition : null) ?? (typeof overview?.moneyAvgPosition === 'number' ? overview.moneyAvgPosition : null); const siteAvgPos28d = (typeof overview?.siteAvgPosition === 'number' && isFinite(overview.siteAvgPosition) && overview.siteAvgPosition > 0) ? overview.siteAvgPosition : null; const moneyAvgPos28d = (typeof overview?.moneyAvgPosition === 'number' && isFinite(overview.moneyAvgPosition) && overview.moneyAvgPosition > 0) ? overview.moneyAvgPosition : null; const moneyPosRatio = (siteAvgPos28d && moneyAvgPos28d) ? (moneyAvgPos28d / siteAvgPos28d) : null; const refBehaviour = typeof refAllMoney?.behaviourScore === 'number' ? refAllMoney.behaviourScore : (typeof window.moneyPagesMetrics?.behaviour?.score === 'number' ? window.moneyPagesMetrics.behaviour.score : null); if (tsSorted.length === 0 || (!moneyClicksShare && !moneyImpressionsShare)) { debugLog(`[KPI Tracker] Cannot calculate rolling series: tsSorted=${tsSorted.length}, clicksShare=${moneyClicksShare}, imprShare=${moneyImpressionsShare}`, 'warn'); for (const _ of datePoints) { for (const key of Object.keys(segments)) segments[key].push(null); } } else { for (const datePoint of datePoints) { const siteRoll = rolling28d(datePoint); // If we don't have a full 28 days ending at this date, show nulls (matches UI expectation). if (siteRoll.days < 28) { for (const key of Object.keys(segments)) segments[key].push(null); continue; } const moneyClicks = Math.round(siteRoll.clicks * moneyClicksShare); const moneyImpressions = Math.round(siteRoll.impressions * moneyImpressionsShare); const segmentTotals = { allMoney: { clicks: moneyClicks, impressions: moneyImpressions }, landingPages: { clicks: Math.round(moneyClicks * (segmentProportions.landingPages.clicks || 0)), impressions: Math.round(moneyImpressions * (segmentProportions.landingPages.impressions || 0)) }, eventPages: { clicks: Math.round(moneyClicks * (segmentProportions.eventPages.clicks || 0)), impressions: Math.round(moneyImpressions * (segmentProportions.eventPages.impressions || 0)) }, productPages: { clicks: Math.round(moneyClicks * (segmentProportions.productPages.clicks || 0)), impressions: Math.round(moneyImpressions * (segmentProportions.productPages.impressions || 0)) } }; for (const key of Object.keys(segments)) { const cls = segmentTotals[key]?.clicks ?? null; const imps = segmentTotals[key]?.impressions ?? null; let v = null; if (metricKey === 'clicks') v = cls; else if (metricKey === 'impressions') v = imps; else if (metricKey === 'ctr') v = imps > 0 ? (cls / imps) * 100 : 0; else if (metricKey === 'avgPosition') { const sitePos = (siteRoll.posImpr > 0) ? (siteRoll.posWeight / siteRoll.posImpr) : null; v = (sitePos && moneyPosRatio) ? (sitePos * moneyPosRatio) : refAvgPos; } else if (metricKey === 'behaviourScore') v = refBehaviour; segments[key].push(v); } } } return { labels, segments, datePoints }; } /** * Render KPI table with trend arrows * @param {{ labels: string[], segments: Record }} historyData * @param {string} metricKey */ function renderMoneyKpiTable(historyData, metricKey) { const tableHead = document.getElementById("money-kpi-header-row"); const tbody = document.querySelector("#money-kpi-table tbody"); if (!tableHead || !tbody) return; const { labels, segments, datePoints = [] } = historyData; // Show message if no data if (!labels || labels.length === 0) { tbody.innerHTML = 'No data available. Run audits to build KPI history.'; return; } const segmentLabels = { allMoney: "All money pages", landingPages: "Landing pages", eventPages: "Event pages", productPages: "Product pages" }; // Convert segments to array for sorting let segmentEntries = Object.entries(segments).map(([key, values]) => { // Calculate trend value for sorting const first = values.find(v => v != null); const last = [...values].reverse().find(v => v != null); let trendValue = 0; if (first != null && last != null) { let diff = last - first; if (metricKey === "avgPosition") diff = -diff; // lower is better trendValue = diff; } return { key, label: segmentLabels[key], values, trendValue, latestValue: last }; }); // Apply sorting const sortArrow = (col) => { if (moneyKpiTableSort.column === col) { return moneyKpiTableSort.direction === 'asc' ? ' ↑' : ' ↓'; } return ''; }; if (moneyKpiTableSort.column === 'segment') { segmentEntries.sort((a, b) => { const comparison = a.label.localeCompare(b.label); return moneyKpiTableSort.direction === 'asc' ? comparison : -comparison; }); } else if (moneyKpiTableSort.column === 'trend') { segmentEntries.sort((a, b) => { const comparison = a.trendValue - b.trendValue; return moneyKpiTableSort.direction === 'asc' ? comparison : -comparison; }); } else if (moneyKpiTableSort.column.startsWith('month_')) { const monthIndex = parseInt(moneyKpiTableSort.column.replace('month_', '')); segmentEntries.sort((a, b) => { const aVal = a.values[monthIndex] ?? (metricKey === 'avgPosition' ? 999 : -1); const bVal = b.values[monthIndex] ?? (metricKey === 'avgPosition' ? 999 : -1); const comparison = aVal - bVal; return moneyKpiTableSort.direction === 'asc' ? comparison : -comparison; }); } else if (moneyKpiTableSort.column === 'latest') { segmentEntries.sort((a, b) => { const aVal = a.latestValue ?? (metricKey === 'avgPosition' ? 999 : -1); const bVal = b.latestValue ?? (metricKey === 'avgPosition' ? 999 : -1); const comparison = aVal - bVal; return moneyKpiTableSort.direction === 'asc' ? comparison : -comparison; }); } // Header with sortable columns - use same format as chart labels (e.g., "17 Nov") tableHead.innerHTML = ` Segment${sortArrow('segment')} ${labels.map((label, idx) => { // Labels are in "17 Nov" format - split into two lines for compact display const parts = label.split(' '); const day = parts[0] || ''; const month = parts[1] || ''; // Use datePoints if available, otherwise just use label for tooltip const fullDate = (datePoints && datePoints[idx]) ? datePoints[idx] : label; return `
${day}
${month}
${sortArrow(`month_${idx}`)}`; }).join("")} Trend${sortArrow('trend')} `; // Add click handlers to header cells tableHead.querySelectorAll('th[data-sort]').forEach(th => { th.addEventListener('click', () => { const col = th.getAttribute('data-sort'); if (moneyKpiTableSort.column === col) { moneyKpiTableSort.direction = moneyKpiTableSort.direction === 'asc' ? 'desc' : 'asc'; } else { moneyKpiTableSort.column = col; moneyKpiTableSort.direction = 'asc'; } renderMoneyKpiTable(historyData, metricKey); }); th.addEventListener('mouseenter', () => { th.style.backgroundColor = '#e2e8f0'; }); th.addEventListener('mouseleave', () => { th.style.backgroundColor = ''; }); }); tbody.innerHTML = segmentEntries .map(({ key, label, values, trendValue }) => { if (!values.length) return ""; let trendArrow = "→"; let trendClass = "kpi-trend-flat"; let trendText = ""; const first = values.find(v => v != null); const last = [...values].reverse().find(v => v != null); if (first != null && last != null) { let diff = last - first; if (metricKey === "avgPosition") diff = -diff; // lower is better if (diff > 0.02 * Math.abs(first || 1)) { trendArrow = "↑"; trendClass = "kpi-trend-up"; } else if (diff < -0.02 * Math.abs(first || 1)) { trendArrow = "↓"; trendClass = "kpi-trend-down"; } // For CTR, values are already percentages (0-100), so diff is already in percentage points // For other metrics, show the raw difference trendText = (metricKey === "ctr" ? `${diff.toFixed(1)}pp` : diff.toFixed(1)); } else { trendText = ""; } const cells = values .map((v, idx) => { if (v == null) return `—`; // arrow vs previous non-null let arrow = "→"; let arrowClass = "kpi-trend-flat"; let prev = null; for (let j = idx - 1; j >= 0; j--) { if (values[j] != null) { prev = values[j]; break; } } if (prev != null) { let d = v - prev; if (metricKey === "avgPosition") d = -d; if (d > 0.02 * Math.abs(prev || 1)) { arrow = "↑"; arrowClass = "kpi-trend-up"; } else if (d < -0.02 * Math.abs(prev || 1)) { arrow = "↓"; arrowClass = "kpi-trend-down"; } } let formatted; const arrowHtml = `${arrow}`; if (metricKey === "ctr") formatted = `${v.toFixed(1)}% ${arrowHtml}`; // v is already a percentage (0-100) else if (metricKey === "avgPosition") formatted = `${v.toFixed(1)} ${arrowHtml}`; else formatted = `${Math.round(v).toLocaleString()} ${arrowHtml}`; return `${formatted}`; }) .join(""); return ` ${label} ${cells} ${trendArrow} ${trendText} `; }) .join(""); } /** * Render sparkline chart for KPI tracker * @param {{ labels: string[], segments: Record }} historyData * @param {string} metricKey */ function renderMoneyKpiSparkline(historyData, metricKey, retryCount = 0) { const ctx = document.getElementById("money-kpi-sparkline"); if (!ctx) return; const { labels, segments } = historyData; // Don't set canvas dimensions manually - let Chart.js handle it with fixed container // The parent container now has a fixed height (300px) set in HTML // Check if panel is visible AFTER setting dimensions const moneyPanel = ctx.closest('.aigeo-panel[data-panel="money"]'); const isPanelActive = moneyPanel && moneyPanel.classList.contains('is-active'); const panelStyle = moneyPanel ? window.getComputedStyle(moneyPanel) : null; const isPanelVisible = !moneyPanel || (isPanelActive && panelStyle && panelStyle.display !== 'none' && panelStyle.visibility !== 'hidden'); const canvasRect = ctx.getBoundingClientRect(); // Wait a bit for layout to settle if panel was just activated if (!isPanelVisible || canvasRect.width === 0 || canvasRect.height === 0) { if (retryCount >= 10) { const wrapper = ctx.closest('.sparkline-wrapper'); if (wrapper) { wrapper.innerHTML = '
Could not render KPI chart (canvas still 0×0). Try switching tabs or refreshing.
'; } return; } // Only log warning if panel is not active (to reduce noise) if (!isPanelActive) { debugLog(`⚠ Money KPI sparkline: Panel hidden or canvas has zero dimensions (width=${canvasRect.width}, height=${canvasRect.height}, panelVisible=${isPanelVisible}). Chart will render when panel is shown.`, 'warn'); // Schedule retry when panel becomes visible if (moneyPanel && !moneyPanel.classList.contains('is-active')) { // Use a one-time observer const observer = new MutationObserver((mutations) => { if (moneyPanel.classList.contains('is-active')) { observer.disconnect(); // Wait a bit longer for layout to settle setTimeout(() => renderMoneyKpiSparkline(historyData, metricKey, retryCount + 1), 200); } }); observer.observe(moneyPanel, { attributes: true, attributeFilter: ['class'] }); // Also set a timeout fallback (max 5 seconds) setTimeout(() => { observer.disconnect(); if (moneyPanel.classList.contains('is-active')) { renderMoneyKpiSparkline(historyData, metricKey, retryCount + 1); } }, 5000); } } else { // Panel is active but canvas still has zero dimensions - retry after a short delay setTimeout(() => renderMoneyKpiSparkline(historyData, metricKey, retryCount + 1), 200); } return; } // Check if we have data const hasData = labels.length > 0 && segments.allMoney.length > 0; if (!hasData || labels.length === 0) { // Show message if no data const wrapper = ctx.closest('.sparkline-wrapper'); if (wrapper) { wrapper.innerHTML = '
No data available. Run audits to build KPI history.
'; } if (moneyKpiChart) { moneyKpiChart.destroy(); moneyKpiChart = null; } return; } // Show info message if only 1 data point if (labels.length === 1) { const wrapper = ctx.closest('.sparkline-wrapper'); if (wrapper && !wrapper.querySelector('.single-point-message')) { const message = document.createElement('div'); message.className = 'single-point-message'; message.style.cssText = 'padding: 0.5rem; margin-bottom: 0.5rem; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 0.85rem; color: #92400e;'; message.textContent = 'Only 1 audit available. Run more audits to see trends over time.'; wrapper.insertBefore(message, ctx); } } else { // Remove message if we have multiple data points const wrapper = ctx.closest('.sparkline-wrapper'); if (wrapper) { const message = wrapper.querySelector('.single-point-message'); if (message) message.remove(); } } const datasets = [ { key: "allMoney", label: "All", values: segments.allMoney, color: "#3b82f6" }, { key: "landingPages", label: "Landing", values: segments.landingPages, color: "#10b981" }, { key: "eventPages", label: "Event", values: segments.eventPages, color: "#f59e0b" }, { key: "productPages", label: "Product", values: segments.productPages, color: "#ef4444" } ]; // Always destroy existing chart to allow re-rendering (e.g., when metric changes) // Also check Chart.js registry to ensure canvas is not already in use if (moneyKpiChart) { try { moneyKpiChart.destroy(); moneyKpiChart = null; } catch (e) { debugLog('Error destroying moneyKpiChart: ' + e.message, 'warn'); moneyKpiChart = null; } } // Also check Chart.js registry and destroy any existing chart on this canvas if (typeof Chart !== 'undefined' && Chart.getChart) { const existingChart = Chart.getChart(ctx); if (existingChart) { try { existingChart.destroy(); debugLog('Destroyed existing Chart.js instance from registry for moneyKpiChart', 'info'); } catch (e) { debugLog('Error destroying chart from registry: ' + e.message, 'warn'); } } } // Clear canvas context to ensure clean slate const tempCtx = ctx.getContext('2d'); if (tempCtx) { tempCtx.clearRect(0, 0, ctx.width, ctx.height); } // Ensure we have at least 2 points for line chart, or show points only const minPoints = labels.length === 1 ? 1 : 2; // Calculate Y-axis bounds to prevent infinite expansion const allValues = segments.allMoney.concat( segments.landingPages || [], segments.eventPages || [], segments.productPages || [] ).filter(v => v != null && !isNaN(v)); let minValue = allValues.length > 0 ? Math.min(...allValues) : 0; let maxValue = allValues.length > 0 ? Math.max(...allValues) : 100; // Calculate padding based on data range const range = maxValue - minValue; const padding = range > 0 ? Math.max(range * 0.1, range * 0.05) : (maxValue > 0 ? maxValue * 0.1 : 0.01); try { moneyKpiChart = new Chart(ctx, { type: "line", data: { labels, datasets: datasets.map(d => ({ label: d.label, data: d.values, fill: false, tension: labels.length > 1 ? 0.3 : 0, // No tension for single point pointRadius: labels.length === 1 ? 5 : 3, // Larger point for single data point pointHoverRadius: 6, borderWidth: 2, borderColor: d.color, backgroundColor: d.color })) }, options: { responsive: true, maintainAspectRatio: true, aspectRatio: 2.5, plugins: { legend: { display: true, position: 'top' }, tooltip: { enabled: true, callbacks: { label: function(context) { const label = context.dataset.label || ''; const value = context.parsed.y; let formatted = ''; if (metricKey === 'ctr') { formatted = `${value.toFixed(2)}%`; // value is already a percentage (0-100) } else if (metricKey === 'avgPosition') { formatted = value.toFixed(1); } else { formatted = Math.round(value).toLocaleString(); } return `${label}: ${formatted}`; } } } }, scales: { x: { display: true, title: { display: true, font: { size: 14, weight: 'bold' }, text: 'Month' }, ticks: { font: { size: 12, weight: 'bold' }, maxRotation: 45, minRotation: 45, autoSkip: true, maxTicksLimit: 12, callback: function(value, index) { // Return shorter date format to prevent overflow const label = this.getLabelForValue(value); if (label && label.length > 10) { // If label is too long, use shorter format (e.g., "12-15" instead of "2025-12-15") const parts = label.split('-'); if (parts.length >= 3) { return `${parts[1]}-${parts[2]}`; } } return label; } } }, y: { display: true, title: { display: true, font: { size: 14, weight: 'bold' }, text: metricKey === 'ctr' ? 'CTR (%)' : metricKey === 'avgPosition' ? 'Avg Position' : metricKey === 'impressions' ? 'Impressions' : metricKey === 'clicks' ? 'Clicks' : (metricKey ? metricKey.charAt(0).toUpperCase() + metricKey.slice(1) : 'Value') }, // Let Chart.js auto-scale - don't force min/max as it can cause issues with small ranges // The maintainAspectRatio and fixed container height will prevent infinite expansion ticks: { font: { size: 12, weight: 'bold' }, callback: function(value) { const v = typeof value === 'number' ? value : parseFloat(value); if (Number.isNaN(v)) return value; if (metricKey === 'ctr') return `${v.toFixed(1)}%`; // v is already a percentage (0-100) if (metricKey === 'avgPosition') return v.toFixed(1); return Math.round(v).toLocaleString(); } } } }, elements: { point: { radius: labels.length === 1 ? 5 : 3 } } } }); debugLog(`✓ Money KPI sparkline chart created successfully with ${labels.length} data points`, 'success'); } catch (error) { debugLog(`✗ Error creating Money KPI sparkline chart: ${error.message}`, 'error'); debugLog(`Error stack: ${error.stack}`, 'error'); const wrapper = ctx.closest('.sparkline-wrapper'); if (wrapper) { wrapper.innerHTML = `
Error rendering chart: ${error.message}
`; } } } /** * Load audit history and render KPI tracker * @param {string} propertyUrl */ // OLD FUNCTION - Using audit history (deprecated, use window.loadAuditHistoryAndRenderKpis instead) async function loadAuditHistoryAndRenderKpis_OLD(propertyUrl) { if (!propertyUrl) return; const endDate = new Date().toISOString().split('T')[0]; const startDate = new Date(); startDate.setFullYear(startDate.getFullYear() - 1); const startDateStr = startDate.toISOString().split('T')[0]; try { // Use window.apiUrl if available (for functions outside the main scope) const urlHelper = window.apiUrl || ((path) => { const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; const baseUrl = isLocal ? 'https://ai-geo-audit.vercel.app' : ''; if (!baseUrl) { return path.startsWith('/') ? path : `/${path}`; } const cleanPath = path.startsWith('/') ? path : `/${path}`; return `${baseUrl}${cleanPath}`; }); const res = await fetch(urlHelper(`/api/supabase/get-audit-history?propertyUrl=${encodeURIComponent(propertyUrl)}&startDate=${startDateStr}&endDate=${endDate}`)); const json = await res.json(); if (json.status !== "ok") { debugLog('⚠ Could not load audit history for KPI tracker', 'warn'); return; } const history = json.data || []; const timeseries = json.timeseries || []; cachedAuditHistory = history; debugLog(`[KPI Tracker] Loaded ${history.length} audit records and ${timeseries.length} timeseries records`, 'info'); debugLog(`[KPI Tracker] API response structure: hasData=${!!json.data}, hasTimeseries=${!!json.timeseries}, timeseriesType=${typeof json.timeseries}, timeseriesIsArray=${Array.isArray(json.timeseries)}`, 'info'); if (timeseries.length > 0) { debugLog(`[KPI Tracker] Timeseries sample: first=${JSON.stringify(timeseries[0])}, last=${JSON.stringify(timeseries[timeseries.length - 1])}`, 'info'); } else { debugLog(`[KPI Tracker] WARNING: timeseries is empty. API response keys: ${Object.keys(json).join(', ')}`, 'warn'); } if (history.length === 0) { const card = document.getElementById("money-pages-kpi-card"); if (card) { const tableWrapper = card.querySelector('.table-wrapper'); if (tableWrapper) { tableWrapper.innerHTML = '
No audit history available. Run audits to build KPI data.
'; } const tbody = card.querySelector('#money-kpi-table tbody'); if (tbody) { tbody.innerHTML = 'No audit history available. Run audits to build KPI data.'; } } debugLog('⚠ Money Pages KPI Tracker: No audit history available', 'warn'); return; } // Get the last GSC timeseries date (Money Pages metrics are GSC-derived, so should only show up to last GSC date) let lastGscTimeseriesDate = null; // Try to get from window first (if renderTrendChart already ran and set it) if (window.lastGscTimeseriesDate) { lastGscTimeseriesDate = window.lastGscTimeseriesDate; debugLog(`Money Pages KPI: Using last GSC timeseries date from window: ${lastGscTimeseriesDate}`, 'info'); } else { // Extract from timeseries data in the API response // The API response should include timeseries data with dates if (json.timeseries && Array.isArray(json.timeseries) && json.timeseries.length > 0) { // Get the latest date from timeseries (dates are in YYYY-MM-DD format) const timeseriesDates = json.timeseries .map(ts => ts.date) .filter(date => date) .sort() .reverse(); if (timeseriesDates.length > 0) { lastGscTimeseriesDate = timeseriesDates[0]; window.lastGscTimeseriesDate = lastGscTimeseriesDate; // Store globally debugLog(`Money Pages KPI: Extracted last GSC timeseries date from API response: ${lastGscTimeseriesDate}`, 'info'); } } } // Filter history to only include dates up to the last GSC timeseries date // Money Pages metrics (clicks, impressions, CTR, position) are GSC-derived, so should only show up to last GSC date let filteredHistory = history; if (lastGscTimeseriesDate) { const beforeFilter = history.length; filteredHistory = history.filter(record => { const recordDate = record.date || record.audit_date || record.auditDate; if (!recordDate) return false; // Compare dates as strings (YYYY-MM-DD format) const comparison = recordDate.localeCompare(lastGscTimeseriesDate); const include = comparison <= 0; if (!include) { debugLog(`Money Pages KPI: Excluding record with date ${recordDate} (last GSC date: ${lastGscTimeseriesDate})`, 'info'); } return include; }); debugLog(`Money Pages KPI: Filtered history from ${beforeFilter} to ${filteredHistory.length} records (last GSC date: ${lastGscTimeseriesDate})`, 'info'); debugLog(`Money Pages KPI: Filtered dates: ${filteredHistory.map(r => r.date || r.audit_date || r.auditDate).join(', ')}`, 'info'); } else { debugLog(`Money Pages KPI: No last GSC timeseries date found, using all history records`, 'warn'); } // Check if any history records have moneySegmentMetrics (API returns camelCase) const hasMetrics = filteredHistory.some(record => record.moneySegmentMetrics); if (!hasMetrics) { debugLog(`⚠ Money Pages KPI Tracker: No moneySegmentMetrics in audit history. History length: ${filteredHistory.length}`, 'warn'); debugLog(`⚠ Sample record keys: ${filteredHistory.length > 0 ? Object.keys(filteredHistory[0]).join(', ') : 'no records'}`, 'warn'); const card = document.getElementById("money-pages-kpi-card"); if (card) { const tableWrapper = card.querySelector('.table-wrapper'); if (tableWrapper) { tableWrapper.innerHTML = '
No money segment metrics found in audit history. Run a new audit to generate KPI data.
'; } const tbody = card.querySelector('#money-kpi-table tbody'); if (tbody) { tbody.innerHTML = 'No money segment metrics found. Run a new audit to generate KPI data.'; } } return; } debugLog(`✓ Money Pages KPI Tracker: Found ${filteredHistory.filter(r => r.moneySegmentMetrics).length} records with moneySegmentMetrics`, 'success'); debugLog(`✓ Money Pages KPI Tracker: Total history records: ${filteredHistory.length}`, 'info'); // Use timeseries data from API response (already extracted above) if (timeseries.length === 0) { debugLog(`⚠ Money Pages KPI Tracker: No timeseries data returned from API. Check API logs.`, 'warn'); debugLog(`⚠ API response keys: ${Object.keys(json).join(', ')}`, 'warn'); debugLog(`⚠ API response status: ${json.status}`, 'warn'); } else { debugLog(`✓ Money Pages KPI Tracker: Using ${timeseries.length} timeseries data points from GSC`, 'info'); debugLog(`✓ Timeseries date range: ${timeseries[0]?.date} to ${timeseries[timeseries.length - 1]?.date}`, 'info'); } // Cache for re-rendering charts on filter changes without re-fetching. window.__moneyPagesTimeseriesCache = timeseries; // IMPORTANT: Do NOT use `filteredHistory` to pick the "latest audit" for money shares. // GSC data is typically 1–2 days behind, so filtering audits to <= lastGscTimeseriesDate can // accidentally drop the most recent audit run (audit_date = today), which is exactly where // we get the latest moneyClicks/moneyImpressions proportions from. const latestAuditForShares = history.slice().reverse().find(r => r && (r.moneySegmentMetrics || r.moneyPagePriorityData)) || (history.length > 0 ? history[history.length - 1] : null); const latestMoneyPagePriorityData = latestAuditForShares?.moneyPagePriorityData || window.moneyPagePriorityData || []; const latestMoneySegmentMetrics = latestAuditForShares?.moneySegmentMetrics || null; const metricKey = document.getElementById("money-kpi-metric-select")?.value || "ctr"; debugLog(`✓ Money Pages KPI Tracker: Using metricKey="${metricKey}"`, 'info'); const historyData = buildMoneyKpiHistory(history, metricKey, timeseries, latestMoneySegmentMetrics, latestMoneyPagePriorityData); debugLog(`✓ Money Pages KPI Tracker: Built history with ${historyData.labels.length} labels`, 'info'); renderMoneyKpiTable(historyData, metricKey); renderMoneyKpiSparkline(historyData, metricKey); // Keep the Money Pages Performance Trends in sync with the KPI tracker. // We pass `timeseries` so the trend charts can use rolling-28d derivation (avoids spikes and // avoids relying on sparse portfolio snapshot rows). if (typeof renderMoneyPagesTrendChart === 'function') { renderMoneyPagesTrendChart([], timeseries); } } catch (error) { debugLog(`⚠ Error loading KPI history: ${error.message}`, 'warn'); } } // Use the timeseries-based KPI loader as the canonical Money Pages KPI implementation. // (Portfolio snapshot rows can be sparse/irregular, which collapses the KPI table to a single week.) window.loadAuditHistoryAndRenderKpis = loadAuditHistoryAndRenderKpis_OLD; // Canonical implementation: KPI Tracker should be based on GSC timeseries + audit-derived proportions, // not portfolio segment snapshot rows (which may only exist for a single audit date). // NOTE: Do not override `window.loadAuditHistoryAndRenderKpis` here. // The canonical implementation is defined later and uses portfolio segment metrics. // Wire up KPI metric selector to reload on change function wireMoneyKpiMetricSelector() { setTimeout(() => { const metricSelect = document.getElementById('money-kpi-metric-select'); if (metricSelect) { const newSelect = metricSelect.cloneNode(true); metricSelect.parentNode.replaceChild(newSelect, metricSelect); newSelect.addEventListener('change', () => { const propertyUrl = document.getElementById('propertyUrl')?.value; if (propertyUrl) { window.loadAuditHistoryAndRenderKpis(propertyUrl); } }); debugLog('✓ Money KPI metric selector wired up', 'success'); } }, 200); } // Render Money Pages Performance Trends charts (split into Volume and Rate charts) function renderMoneyPagesTrendChart(history, timeseries = null) { const volumeCanvas = document.getElementById('moneyPagesVolumeChart'); const rateCanvas = document.getElementById('moneyPagesRateChart'); if (!volumeCanvas || !rateCanvas) { debugLog(`⚠ Money Pages trend chart canvas(es) not found - volumeCanvas: ${!!volumeCanvas}, rateCanvas: ${!!rateCanvas}`, 'warn'); debugLog(`⚠ Looking for canvas elements - volumeCanvas ID: moneyPagesVolumeChart, rateCanvas ID: moneyPagesRateChart`, 'warn'); // Try to find any canvas elements in the Money Pages section const moneySection = document.getElementById('money-pages-section'); if (moneySection) { const allCanvases = moneySection.querySelectorAll('canvas'); debugLog(`⚠ Found ${allCanvases.length} canvas elements in money-pages-section`, 'warn'); allCanvases.forEach((canvas, idx) => { debugLog(`⚠ Canvas ${idx}: id=${canvas.id}, tagName=${canvas.tagName}`, 'warn'); }); } return; } // IMPORTANT: Only render Money Pages Performance Trends from GSC daily timeseries. // This prevents "stale then flip" on load caused by other parts of the dashboard calling // `renderMoneyPagesTrendChart(history)` for unrelated charts (history-only), which would // paint these canvases before the KPI loader provides the canonical timeseries. if (!Array.isArray(timeseries) || timeseries.length === 0) { // Clear canvases to avoid showing stale previous chart instances. [volumeCanvas, rateCanvas].forEach(canvas => { try { const ctx = canvas.getContext('2d'); if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); } catch (e) { // ignore } }); window.moneyPagesChartsRendering = false; return; } // Destroy existing charts if they exist // Also check if charts are already being rendered to prevent loops if (window.moneyPagesVolumeChart) { try { // Check if chart is still valid before destroying if (window.moneyPagesVolumeChart.canvas && window.moneyPagesVolumeChart.canvas.id === 'moneyPagesVolumeChart') { window.moneyPagesVolumeChart.destroy(); } } catch (e) { // Ignore destroy errors } window.moneyPagesVolumeChart = null; } if (window.moneyPagesRateChart) { try { // Check if chart is still valid before destroying if (window.moneyPagesRateChart.canvas && window.moneyPagesRateChart.canvas.id === 'moneyPagesRateChart') { window.moneyPagesRateChart.destroy(); } } catch (e) { // Ignore destroy errors } window.moneyPagesRateChart = null; } // Prevent re-rendering loops: if a render is in progress, queue the latest request and run it after. if (window.moneyPagesChartsRendering) { window.moneyPagesChartsPending = { history, timeseries }; return; } window.moneyPagesChartsRendering = true; const hasTimeseries = Array.isArray(timeseries) && timeseries.length > 0; if ((!history || !Array.isArray(history) || history.length === 0) && !hasTimeseries) { debugLog('⚠ Money Pages trend chart: No history data available', 'warn'); // Set canvas dimensions before drawing text [volumeCanvas, rateCanvas].forEach(canvas => { if (canvas) { canvas.width = canvas.offsetWidth || 800; canvas.height = canvas.offsetHeight || 400; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.font = '14px Arial'; ctx.fillStyle = '#64748b'; ctx.textAlign = 'center'; ctx.fillText('No trend data available. Run audits to build trend data.', canvas.width / 2, canvas.height / 2); } }); return; } // Current top-level sub-segment filter (ALL/LANDING/EVENT/PRODUCT). const subSegRaw = (document.getElementById('money-top-level-filter-subsegment')?.value || window.moneyPagesSubSegmentFilter || 'ALL'); const subSeg = String(subSegRaw).toUpperCase(); const segmentKey = subSeg === 'PRODUCT' ? 'productPages' : subSeg === 'EVENT' ? 'eventPages' : subSeg === 'LANDING' ? 'landingPages' : 'allMoney'; // Behaviour score: recompute from the same derived CTR series (plus a segment-specific top10CTR anchor from page aggregates). const aggFn = window.computeMoneyPagesBehaviourFromPageAggregates; const rowsAll = (window.currentMoneyPagesMetrics || window.moneyPagesMetrics)?.rows || []; const rowsForSubSeg = (seg) => { if (!Array.isArray(rowsAll) || rowsAll.length === 0) return []; if (seg === 'ALL') return rowsAll; return rowsAll.filter(r => { const s = (r?.subSegment || r?.segmentType || 'LANDING'); return String(s).toUpperCase() === seg; }); }; const top10CtrByKey = { allMoney: (typeof aggFn === 'function' ? aggFn(rowsForSubSeg('ALL'))?.top10Ctr : null), landingPages: (typeof aggFn === 'function' ? aggFn(rowsForSubSeg('LANDING'))?.top10Ctr : null), eventPages: (typeof aggFn === 'function' ? aggFn(rowsForSubSeg('EVENT'))?.top10Ctr : null), productPages: (typeof aggFn === 'function' ? aggFn(rowsForSubSeg('PRODUCT'))?.top10Ctr : null) }; const behaviourFromCtr = (ctrRatio, top10CtrRatio) => { if (!ctrRatio || !top10CtrRatio) return null; const scoreCtrAll = Math.min(ctrRatio / 0.05, 1) * 100; // 0–5% => 0–100 const scoreCtrTop10 = Math.min(top10CtrRatio / 0.10, 1) * 100; // 0–10% => 0–100 return 0.5 * scoreCtrAll + 0.5 * scoreCtrTop10; }; // IMPORTANT: Money Pages Trend charts are "last 28 days (rolling)" metrics. // We have daily site totals in `gsc_timeseries`, so compute rolling-28d site totals and apply // the current money-page share to avoid discontinuities from older audits that used different // segmentation/aggregation logic. if (Array.isArray(timeseries) && timeseries.length > 0) { try { const tsSorted = timeseries .filter(ts => ts && ts.date) .slice() .sort((a, b) => String(a.date).localeCompare(String(b.date))); const clamp01 = (x) => Math.max(0, Math.min(1, x)); const rolling28d = (endDateStr) => { const end = new Date(endDateStr + 'T00:00:00'); if (Number.isNaN(end.getTime())) return { clicks: 0, impressions: 0, days: 0 }; const start = new Date(end); start.setDate(start.getDate() - 27); const startStr = start.toISOString().split('T')[0]; let clicks = 0; let impressions = 0; let days = 0; for (const ts of tsSorted) { const d = String(ts.date); if (d < startStr) continue; if (d > endDateStr) break; clicks += Number(ts.clicks || 0); impressions += Number(ts.impressions || 0); days += 1; } return { clicks, impressions, days }; }; const endDateStr = tsSorted[tsSorted.length - 1].date; const siteRollAtEnd = rolling28d(endDateStr); const overview = window.moneyPagesMetrics?.overview || null; const moneyClicks28d = Number(overview?.moneyClicks ?? 0); const moneyImpressions28d = Number(overview?.moneyImpressions ?? 0); const clicksShare = siteRollAtEnd.clicks > 0 ? clamp01(moneyClicks28d / siteRollAtEnd.clicks) : 0; const imprShare = siteRollAtEnd.impressions > 0 ? clamp01(moneyImpressions28d / siteRollAtEnd.impressions) : 0; const behaviourScore = typeof window.moneyPagesMetrics?.behaviour?.score === 'number' ? window.moneyPagesMetrics.behaviour.score : null; // Split money totals into sub-segments using latest money segment proportions if available. const segSource = window.moneySegmentMetrics || null; const segAll = segSource?.allMoney || null; const allClicks = Number(segAll?.clicks || moneyClicks28d || 0); const allImps = Number(segAll?.impressions || moneyImpressions28d || 0); const segPropClicks = { landingPages: allClicks > 0 ? Number(segSource?.landingPages?.clicks || 0) / allClicks : 0, eventPages: allClicks > 0 ? Number(segSource?.eventPages?.clicks || 0) / allClicks : 0, productPages: allClicks > 0 ? Number(segSource?.productPages?.clicks || 0) / allClicks : 0 }; const segPropImps = { landingPages: allImps > 0 ? Number(segSource?.landingPages?.impressions || 0) / allImps : 0, eventPages: allImps > 0 ? Number(segSource?.eventPages?.impressions || 0) / allImps : 0, productPages: allImps > 0 ? Number(segSource?.productPages?.impressions || 0) / allImps : 0 }; const derivedHistory = tsSorted.map(ts => { const roll = rolling28d(ts.date); if (roll.days < 28) { return { date: ts.date, isPartial: false, moneySegmentMetrics: { allMoney: { clicks: null, impressions: null, ctr: null, avgPosition: null, behaviourScore: null }, landingPages: { clicks: null, impressions: null, ctr: null, avgPosition: null, behaviourScore: null }, eventPages: { clicks: null, impressions: null, ctr: null, avgPosition: null, behaviourScore: null }, productPages: { clicks: null, impressions: null, ctr: null, avgPosition: null, behaviourScore: null } } }; } const clicks = Math.round(roll.clicks * clicksShare); const imps = Math.round(roll.impressions * imprShare); const ctr = imps > 0 ? (clicks / imps) : 0; const landingClicks = Math.round(clicks * (segPropClicks.landingPages || 0)); const eventClicks = Math.round(clicks * (segPropClicks.eventPages || 0)); const productClicks = Math.round(clicks * (segPropClicks.productPages || 0)); const landingImps = Math.round(imps * (segPropImps.landingPages || 0)); const eventImps = Math.round(imps * (segPropImps.eventPages || 0)); const productImps = Math.round(imps * (segPropImps.productPages || 0)); const landingCtr = landingImps > 0 ? (landingClicks / landingImps) : 0; const eventCtr = eventImps > 0 ? (eventClicks / eventImps) : 0; const productCtr = productImps > 0 ? (productClicks / productImps) : 0; const avgPos = (typeof overview?.moneyAvgPosition === 'number' ? overview.moneyAvgPosition : null); return { date: ts.date, isPartial: false, moneySegmentMetrics: { allMoney: { clicks, impressions: imps, ctr, avgPosition: avgPos, behaviourScore: null }, landingPages: { clicks: landingClicks, impressions: landingImps, ctr: landingCtr, avgPosition: avgPos, behaviourScore: null }, eventPages: { clicks: eventClicks, impressions: eventImps, ctr: eventCtr, avgPosition: avgPos, behaviourScore: null }, productPages: { clicks: productClicks, impressions: productImps, ctr: productCtr, avgPosition: avgPos, behaviourScore: null } } }; }); history = derivedHistory; debugLog( `✓ Money Pages trend chart: using rolling-28d derived history (${derivedHistory.length} pts, clicksShare=${(clicksShare * 100).toFixed(2)}%, imprShare=${(imprShare * 100).toFixed(2)}%)`, 'success' ); } catch (e) { debugLog(`⚠ Money Pages trend chart: rolling derivation failed (${e.message}), falling back to historical history`, 'warn'); } } // Get the last GSC timeseries date (Money Pages metrics are GSC-derived) // Use actual GSC data date, not audit date - GSC data is typically 2-3 days behind let lastGscTimeseriesDate = window.lastGscTimeseriesDate || null; // If not set, try to get from the latest history record's actual GSC date if (!lastGscTimeseriesDate && history.length > 0) { // Find the latest date that has moneySegmentMetrics (actual GSC data) const recordsWithMetrics = history .filter(r => r.moneySegmentMetrics && !r.isPartial) .map(r => r.date || r.audit_date || r.auditDate) .filter(Boolean) .sort() .reverse(); if (recordsWithMetrics.length > 0) { // Use the latest date that has actual GSC metrics lastGscTimeseriesDate = recordsWithMetrics[0]; debugLog(`Money Pages Trend Chart: Using latest date with GSC metrics: ${lastGscTimeseriesDate}`, 'info'); } } debugLog(`Money Pages Trend Chart: Starting with ${history.length} history records, lastGscTimeseriesDate: ${lastGscTimeseriesDate}`, 'info'); // Calculate the cutoff date for last 28 days (matches GSC data window) // Use the last GSC date if available, otherwise use today // Go back 27 days to get 28 days total (inclusive of end date) const cutoffDate = lastGscTimeseriesDate ? new Date(lastGscTimeseriesDate) : new Date(); const twentyEightDaysAgo = new Date(cutoffDate); twentyEightDaysAgo.setDate(twentyEightDaysAgo.getDate() - 27); // 27 days back + end date = 28 days total const twentyEightDaysAgoStr = twentyEightDaysAgo.toISOString().split('T')[0]; debugLog(`Money Pages Trend Chart: Showing last 28 days up to ${cutoffDate.toISOString().split('T')[0]} (from ${twentyEightDaysAgoStr})`, 'info'); // Filter history to only include: // 1. Dates up to the last GSC timeseries date // 2. Dates within the last 28 days // 3. EXCLUDE partial audits (isPartial=true) to prevent unnatural spikes let filteredHistory = history; if (lastGscTimeseriesDate) { const beforeFilter = history.length; filteredHistory = history.filter(record => { const recordDate = record.date || record.audit_date || record.auditDate; if (!recordDate) return false; // Exclude partial audits - they can have incomplete/incorrect data if (record.isPartial === true) { debugLog(`Money Pages Trend: Excluding partial audit ${recordDate} to prevent spikes`, 'info'); return false; } // Check if date is within last 28 days const isWithin28Days = recordDate.localeCompare(twentyEightDaysAgoStr) >= 0; // Check if date is up to last GSC date const isUpToGscDate = recordDate.localeCompare(lastGscTimeseriesDate) <= 0; const include = isWithin28Days && isUpToGscDate; if (!include) { debugLog(`Money Pages Trend: Excluding record with date ${recordDate} (28-day cutoff: ${twentyEightDaysAgoStr}, last GSC date: ${lastGscTimeseriesDate})`, 'info'); } return include; }); debugLog(`Money Pages Trend: Filtered history from ${beforeFilter} to ${filteredHistory.length} records (last 28 days up to GSC date: ${lastGscTimeseriesDate}, excluding partial audits)`, 'info'); debugLog(`Money Pages Trend: Filtered dates: ${filteredHistory.map(r => r.date || r.audit_date || r.auditDate).join(', ')}`, 'info'); } else { // If no GSC date, just filter by last 28 days and exclude partial audits const beforeFilter = history.length; filteredHistory = history.filter(record => { const recordDate = record.date || record.audit_date || record.auditDate; if (!recordDate) return false; // Exclude partial audits - they can have incomplete/incorrect data if (record.isPartial === true) { debugLog(`Money Pages Trend: Excluding partial audit ${recordDate} to prevent spikes`, 'info'); return false; } const isWithin28Days = recordDate.localeCompare(twentyEightDaysAgoStr) >= 0; return isWithin28Days; }); debugLog(`Money Pages Trend: Filtered history from ${beforeFilter} to ${filteredHistory.length} records (last 28 days, no GSC date limit, excluding partial audits)`, 'info'); } // Create a map of date -> record for quick lookup const historyMap = new Map(); filteredHistory.forEach(record => { const dateStr = record.date || record.audit_date || record.auditDate; if (dateStr) { historyMap.set(dateStr, record); // Debug: log records with money pages data if (record.moneySegmentMetrics || record.moneyPagesSummary) { debugLog(`Money Pages Trend: Found record for ${dateStr} with data: clicks=${record.moneySegmentMetrics?.allMoney?.clicks || 'null'}, impressions=${record.moneySegmentMetrics?.allMoney?.impressions || 'null'}`, 'info'); } } }); debugLog(`Money Pages Trend: Created historyMap with ${historyMap.size} entries`, 'info'); // Generate 8 weekly date points for last 28 days (up to GSC date) // 28 days / 4 = 7 weeks, so 8 points (one per week including start and end) const endDate = lastGscTimeseriesDate ? new Date(lastGscTimeseriesDate + 'T00:00:00') : new Date(); const startDate = new Date(twentyEightDaysAgo); const allDates = []; const numPoints = 8; // Weekly points: 7 intervals of 4 days each const totalDays = 28; const step = (totalDays - 1) / (numPoints - 1); // 8 points = 7 intervals // Ensure we're comparing dates correctly (set time to midnight) endDate.setHours(0, 0, 0, 0); startDate.setHours(0, 0, 0, 0); // Generate weekly date points for (let i = 0; i < numPoints; i++) { const date = new Date(startDate); date.setDate(date.getDate() + Math.round(i * step)); date.setHours(0, 0, 0, 0); const dateStr = date.toISOString().split('T')[0]; allDates.push(dateStr); } // Ensure last point is exactly endDate if (allDates.length > 0) { allDates[allDates.length - 1] = endDate.toISOString().split('T')[0]; } debugLog(`Money Pages Trend: Generated ${allDates.length} weekly date points in range (${allDates[0]} to ${allDates[allDates.length - 1]})`, 'info'); debugLog(`Money Pages Trend: Date range: startDate=${startDate.toISOString().split('T')[0]}, endDate=${endDate.toISOString().split('T')[0]}, twentyEightDaysAgoStr=${twentyEightDaysAgoStr}`, 'info'); // Extract money pages trend data, filling in weekly dates const labels = []; const clicksData = []; const impressionsData = []; const ctrData = []; const behaviourData = []; // Track first available values (for backward-filling) and last known values (for forward-filling) let firstClicks = null; let firstImpressions = null; let firstCtr = null; let firstBehaviour = null; let lastClicks = null; let lastImpressions = null; let lastCtr = null; let lastBehaviour = null; // Find the first available audit record (for backward-filling the first point) let firstAvailableRecord = null; let firstAvailableDateStr = null; for (const [dateStr, record] of historyMap.entries()) { if (!record.isPartial) { const segmentMetrics = record.moneySegmentMetrics || {}; const seg = segmentMetrics[segmentKey] || {}; const hasZeroMetrics = (seg.clicks === 0 && seg.impressions === 0 && (seg.ctr === 0 || seg.ctr == null)); if (!hasZeroMetrics) { firstAvailableRecord = record; firstAvailableDateStr = dateStr; break; } } } allDates.forEach((dateStr, dateIndex) => { const date = new Date(dateStr); labels.push(date.toLocaleDateString('en-GB', { month: 'short', day: 'numeric' })); // For weekly points, find the nearest audit record (exact match or closest before this date) let record = historyMap.get(dateStr); if (!record) { // Find the closest audit date before or on this weekly point date const weeklyDate = new Date(dateStr); let closestDate = null; let closestRecord = null; historyMap.forEach((rec, recDateStr) => { const recDate = new Date(recDateStr); if (recDate <= weeklyDate && (!closestDate || recDate > closestDate)) { closestDate = recDate; closestRecord = rec; } }); record = closestRecord; } // If still no record found and this is the first date point, use the first available record (backward-fill) if (!record && dateIndex === 0 && firstAvailableRecord) { record = firstAvailableRecord; debugLog(`Money Pages Trend: First date point ${dateStr} has no data, backward-filling from first available audit ${firstAvailableDateStr}`, 'info'); } if (record) { // Skip partial audits or audits with all-zero metrics (like Dec 15) // Check if this is a partial audit or has invalid zero data const isPartial = record.isPartial === true; const segmentMetrics = record.moneySegmentMetrics || {}; const seg = segmentMetrics[segmentKey] || {}; const hasZeroMetrics = (seg.clicks === 0 && seg.impressions === 0 && (seg.ctr === 0 || seg.ctr == null)); // If partial audit or has all zeros, skip this record and forward-fill from last known value if (isPartial || hasZeroMetrics) { debugLog(`Money Pages Trend: Skipping ${dateStr} (isPartial=${isPartial}, hasZeroMetrics=${hasZeroMetrics}), forward-filling`, 'info'); clicksData.push(lastClicks); impressionsData.push(lastImpressions); ctrData.push(lastCtr); behaviourData.push(lastBehaviour); return; // Use return instead of continue in forEach } // Get money pages summary data (shareOfImpressions, shareOfClicks, ctr) const summary = record.moneyPagesSummary || {}; // Get behaviour score let behaviourScore = (typeof seg.behaviourScore === 'number' ? seg.behaviourScore : null); // For clicks and impressions, use moneySegmentMetrics if available const clicks = (seg.clicks != null ? seg.clicks : null); const impressions = (seg.impressions != null ? seg.impressions : null); // Calculate CTR directly from clicks/impressions for accuracy // If clicks and impressions are available, calculate CTR as percentage // Otherwise fall back to stored values (assuming they're in decimal format 0-1) let ctr = null; if (clicks != null && impressions != null && impressions > 0) { ctr = (clicks / impressions) * 100; } else if (summary.ctr != null) { // summary.ctr might be decimal (0.015) or percentage (1.5) // If > 1, assume it's already a percentage; otherwise multiply by 100 ctr = summary.ctr > 1 ? summary.ctr : summary.ctr * 100; } else if ((segmentMetrics.allMoney || {}).ctr != null) { // allMoney.ctr might be decimal (0.015) or percentage (1.5) // If > 1, assume it's already a percentage; otherwise multiply by 100 const fallbackAll = segmentMetrics.allMoney || {}; ctr = fallbackAll.ctr > 1 ? fallbackAll.ctr : fallbackAll.ctr * 100; } if (behaviourScore == null && clicks != null && impressions != null && impressions > 0) { const ctrRatio = clicks / impressions; const top10Ctr = top10CtrByKey[segmentKey] ?? top10CtrByKey.allMoney; const derived = behaviourFromCtr(ctrRatio, top10Ctr); if (derived != null) behaviourScore = derived; } // (Removed noisy Dec 14 debug logging that referenced undefined locals and could crash rendering) // Track first available values (for backward-filling) if (firstClicks == null && clicks != null) firstClicks = clicks; if (firstImpressions == null && impressions != null) firstImpressions = impressions; if (firstCtr == null && ctr != null) firstCtr = ctr; if (firstBehaviour == null && behaviourScore != null) firstBehaviour = behaviourScore; // Update last known values if (clicks != null) lastClicks = clicks; if (impressions != null) lastImpressions = impressions; if (ctr != null) lastCtr = ctr; if (behaviourScore != null) lastBehaviour = behaviourScore; clicksData.push(clicks); impressionsData.push(impressions); ctrData.push(ctr); behaviourData.push(behaviourScore); } else { // No audit data for this date - forward-fill from last known value clicksData.push(lastClicks); impressionsData.push(lastImpressions); ctrData.push(lastCtr); behaviourData.push(lastBehaviour); } }); // Backward-fill from first available value // This ensures dates before the first data point also get filled if (firstClicks != null || firstImpressions != null || firstCtr != null || firstBehaviour != null) { for (let i = 0; i < clicksData.length; i++) { if (clicksData[i] == null && firstClicks != null) clicksData[i] = firstClicks; if (impressionsData[i] == null && firstImpressions != null) impressionsData[i] = firstImpressions; if (ctrData[i] == null && firstCtr != null) ctrData[i] = firstCtr; if (behaviourData[i] == null && firstBehaviour != null) behaviourData[i] = firstBehaviour; } debugLog(`Money Pages Trend: Backward-filled from first values (clicks: ${firstClicks}, impressions: ${firstImpressions}, ctr: ${firstCtr}, behaviour: ${firstBehaviour})`, 'info'); } debugLog(`Money Pages Trend: Populated ${labels.length} dates with data (${clicksData.filter(v => v != null).length} clicks values, ${impressionsData.filter(v => v != null).length} impressions values)`, 'info'); debugLog(`Money Pages Trend: Sample data - First 5 dates: ${labels.slice(0, 5).join(', ')}, Last 5 dates: ${labels.slice(-5).join(', ')}`, 'info'); debugLog(`Money Pages Trend: Sample clicks - First 5: ${clicksData.slice(0, 5).map(v => v != null ? v : 'null').join(', ')}, Last 5: ${clicksData.slice(-5).map(v => v != null ? v : 'null').join(', ')}`, 'info'); if (labels.length === 0) { debugLog('⚠ Money Pages trend chart: No valid data points after filtering', 'warn'); // Set canvas dimensions before drawing text canvas.width = canvas.offsetWidth || 800; canvas.height = canvas.offsetHeight || 400; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.font = '14px Arial'; ctx.fillStyle = '#64748b'; ctx.textAlign = 'center'; ctx.fillText('No trend data available. Run audits to build trend data.', canvas.width / 2, canvas.height / 2); return; } // Ensure canvases have dimensions before creating charts // Use parent container dimensions (Chart.js will handle responsive sizing) [volumeCanvas, rateCanvas].forEach(canvas => { if (canvas) { const parentContainer = canvas.parentElement; if (parentContainer) { // Don't set canvas.width/height directly - let Chart.js handle it // Just ensure the parent has a height if (!parentContainer.style.height) { parentContainer.style.height = '300px'; } } } }); debugLog(`Money Pages Trend Chart: Creating chart with ${labels.length} data points`, 'info'); debugLog(`Money Pages Trend Chart: Clicks data: ${clicksData.filter(v => v != null).length} values, Impressions: ${impressionsData.filter(v => v != null).length} values, CTR: ${ctrData.filter(v => v != null).length} values, Behaviour: ${behaviourData.filter(v => v != null).length} values`, 'info'); debugLog(`Money Pages Trend Chart: Labels: ${labels.join(', ')}`, 'info'); // Check if Chart.js is available if (typeof Chart === 'undefined') { debugLog('⚠ Money Pages trend chart: Chart.js not loaded', 'warn'); [volumeCanvas, rateCanvas].forEach(canvas => { if (canvas) { canvas.width = canvas.offsetWidth || 800; canvas.height = canvas.offsetHeight || 300; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.font = '14px Arial'; ctx.fillStyle = '#ef4444'; ctx.textAlign = 'center'; ctx.fillText('Chart.js library not loaded', canvas.width / 2, canvas.height / 2); } }); return; } try { const volumeCtx = volumeCanvas.getContext('2d'); const rateCtx = rateCanvas.getContext('2d'); if (!volumeCtx || !rateCtx) { debugLog('⚠ Money Pages trend chart: Could not get canvas context', 'warn'); return; } debugLog(`Money Pages Trend Chart: Creating two Chart.js instances (Volume and Rate)...`, 'info'); // Chart 1: Volume Metrics (Clicks + Impressions) window.moneyPagesVolumeChart = new Chart(volumeCtx, { type: 'line', data: { labels: labels, datasets: [ { label: 'Clicks', data: clicksData, borderColor: '#2563eb', backgroundColor: 'rgba(37, 99, 235, 0.1)', yAxisID: 'y', tension: 0.4, spanGaps: true }, { label: 'Impressions', data: impressionsData, borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.1)', yAxisID: 'y1', tension: 0.4, spanGaps: true } ] }, options: { responsive: true, maintainAspectRatio: true, aspectRatio: 2.5, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: true, position: 'top' }, tooltip: { callbacks: { label: function(context) { let label = context.dataset.label || ''; if (label) { label += ': '; } label += context.parsed.y.toLocaleString(); return label; } } } }, scales: { x: { display: true, title: { display: true, font: { size: 14, weight: 'bold' }, text: 'Audit Date' }, ticks: { font: { size: 12, weight: 'bold' }, maxRotation: 45, minRotation: 45, autoSkip: true, maxTicksLimit: 15 } }, y: { type: 'linear', display: true, position: 'left', title: { display: true, font: { size: 14, weight: 'bold' }, text: 'Clicks' }, ticks: { font: { size: 12, weight: 'bold' }, callback: function(value) { return value.toLocaleString(); } } }, y1: { type: 'linear', display: true, position: 'right', title: { display: true, font: { size: 14, weight: 'bold' }, text: 'Impressions' }, grid: { drawOnChartArea: false }, ticks: { font: { size: 12, weight: 'bold' }, callback: function(value) { return value.toLocaleString(); } } } } } }); // Chart 2: Rate & Score Metrics (CTR + Behaviour Score) window.moneyPagesRateChart = new Chart(rateCtx, { type: 'line', data: { labels: labels, datasets: [ { label: 'CTR (%)', data: ctrData, borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.1)', yAxisID: 'y', tension: 0.4, spanGaps: true }, { label: 'Behaviour Score', data: behaviourData, borderColor: '#8b5cf6', backgroundColor: 'rgba(139, 92, 246, 0.1)', yAxisID: 'y1', tension: 0.4, spanGaps: true } ] }, options: { responsive: true, maintainAspectRatio: true, aspectRatio: 2.5, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: true, position: 'top' }, tooltip: { callbacks: { label: function(context) { let label = context.dataset.label || ''; if (label) { label += ': '; } const value = context.parsed.y; // Use 2 decimal places for CTR to match axis precision if (context.dataset.label === 'CTR (%)') { label += value.toFixed(2) + '%'; } else { label += value.toFixed(1); } return label; } } } }, scales: { x: { display: true, title: { display: true, font: { size: 14, weight: 'bold' }, text: 'Audit Date' }, ticks: { font: { size: 12, weight: 'bold' }, maxRotation: 45, minRotation: 45, autoSkip: true, maxTicksLimit: 15 } }, y: { type: 'linear', display: true, position: 'left', title: { display: true, font: { size: 14, weight: 'bold' }, text: 'CTR (%)' }, ticks: { font: { size: 12, weight: 'bold' }, stepSize: 0.02, precision: 2, callback: function(value) { return value.toFixed(2) + '%'; } }, grid: { color: 'rgba(0, 0, 0, 0.1)' } }, y1: { type: 'linear', display: true, position: 'right', title: { display: true, font: { size: 14, weight: 'bold' }, text: 'Behaviour Score' }, grid: { drawOnChartArea: false }, ticks: { font: { size: 12, weight: 'bold' }, callback: function(value) { return value.toFixed(1); } } } } } }); debugLog('✓ Money Pages trend charts rendered successfully', 'success'); window.moneyPagesChartsRendering = false; if (window.moneyPagesChartsPending) { const pending = window.moneyPagesChartsPending; window.moneyPagesChartsPending = null; setTimeout(() => { try { renderMoneyPagesTrendChart(pending.history, pending.timeseries); } catch (e) { // ignore } }, 0); } } catch (error) { window.moneyPagesChartsRendering = false; debugLog(`✗ Error creating Money Pages trend charts: ${error.message}`, 'error'); debugLog(`Money Pages trend chart error details: ${error.stack || error.toString()}`, 'error'); // Show error message on canvases [volumeCanvas, rateCanvas].forEach(canvas => { if (canvas) { const ctx = canvas.getContext('2d'); if (ctx) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.font = '14px Arial'; ctx.fillStyle = '#ef4444'; ctx.textAlign = 'center'; ctx.fillText(`Error rendering chart: ${error.message}`, canvas.width / 2, canvas.height / 2); } } }); } } // Make functions globally available window.renderMoneyPagesTrendChart = renderMoneyPagesTrendChart; window.wireMoneyKpiMetricSelector = wireMoneyKpiMetricSelector; // Recalculate behaviour for filtered sub-segment function recalculateMoneyPagesBehaviour(moneyPagesMetrics, queryPages, subSegmentFilter) { if (!moneyPagesMetrics || !moneyPagesMetrics.rows || !queryPages || subSegmentFilter === 'ALL') { return moneyPagesMetrics?.behaviour || null; } // Get filtered money page URLs const filteredRows = moneyPagesMetrics.rows.filter(row => row.subSegment === subSegmentFilter); if (filteredRows.length === 0) return null; // Recalculate behaviour for filtered URLs only (use all positions for filtered calculations) return window.computeMoneyPagesBehaviour(queryPages, filteredRows, true); } // Render Money Pages Behaviour KPIs // behaviour: pre-calculated behaviour object (already filtered if needed) // moneyPagesMetrics: optional, for backward compatibility // queryPages: optional, for backward compatibility function renderMoneyPagesBehaviourKpis(behaviour, moneyPagesMetrics = null, queryPages = null) { const scoreEl = document.getElementById('money-behaviour-score'); const statusEl = document.getElementById('money-behaviour-status'); const ctrEl = document.getElementById('money-ctr-value'); const top10El = document.getElementById('money-top10-ctr-value'); const cardEl = document.getElementById('money-behaviour-card'); debugLog(`🎯 renderMoneyPagesBehaviourKpis called: behaviour=${!!behaviour}, moneyPagesMetrics=${!!moneyPagesMetrics}, queryPages=${!!queryPages}`, 'info'); debugLog(`🎯 DOM elements found: scoreEl=${!!scoreEl}, statusEl=${!!statusEl}, ctrEl=${!!ctrEl}, top10El=${!!top10El}`, 'info'); // Use the behaviour passed in (should already be filtered) // Only recalculate if behaviour is null and we have the data to calculate it let filteredBehaviour = behaviour; // Fallback: if no behaviour passed but we have metrics and queryPages, try to calculate if (!filteredBehaviour && moneyPagesMetrics && queryPages) { debugLog(`🎯 No behaviour passed, attempting to calculate from filtered metrics`, 'info'); // Get filtered metrics based on ALL current filters const filteredMetrics = getFilteredMoneyPagesMetrics(moneyPagesMetrics); if (filteredMetrics && filteredMetrics.rows && filteredMetrics.rows.length > 0) { debugLog(`🎯 Calculating behaviour for ${filteredMetrics.rows.length} filtered rows`, 'info'); filteredBehaviour = window.computeMoneyPagesBehaviour ? window.computeMoneyPagesBehaviour(queryPages, filteredMetrics.rows, true) : null; debugLog(`🎯 Calculated behaviour: ${!!filteredBehaviour}, impressions: ${filteredBehaviour?.impressions || 0}`, 'info'); } else { debugLog(`🎯 No filtered metrics or rows available`, 'warn'); } } // Fallback #2: compute from page-level aggregates when queryPages is missing/truncated if ((!filteredBehaviour || !filteredBehaviour.impressions) && moneyPagesMetrics) { const filteredMetrics = getFilteredMoneyPagesMetrics(moneyPagesMetrics); if (filteredMetrics && Array.isArray(filteredMetrics.rows) && filteredMetrics.rows.length > 0) { const aggFn = window.computeMoneyPagesBehaviourFromPageAggregates; const aggBehaviour = typeof aggFn === 'function' ? aggFn(filteredMetrics.rows) : null; if (aggBehaviour && aggBehaviour.impressions) { filteredBehaviour = aggBehaviour; debugLog(`🎯 Fallback behaviour (page aggregates): impressions=${aggBehaviour.impressions}, clicks=${aggBehaviour.clicks}`, 'info'); } } } if (!scoreEl) { debugLog(`🎯 ⚠ scoreEl not found in DOM`, 'warn'); return; } if (!filteredBehaviour || !filteredBehaviour.impressions) { debugLog(`🎯 ⚠ No valid behaviour data: filteredBehaviour=${!!filteredBehaviour}, impressions=${filteredBehaviour?.impressions || 0}`, 'warn'); if (scoreEl) scoreEl.textContent = 'N/A'; if (statusEl) statusEl.textContent = 'Not enough data for money pages in this window.'; if (ctrEl) ctrEl.textContent = '—'; if (top10El) top10El.textContent = '—'; if (cardEl) { cardEl.classList.remove('status-green', 'status-amber', 'status-red'); cardEl.style.borderLeft = ''; cardEl.style.background = ''; } return; } const score = Math.round(filteredBehaviour.score); const ctrPct = (filteredBehaviour.siteCtr * 100); const top10Pct = (filteredBehaviour.top10Ctr * 100); scoreEl.textContent = `${score}/100`; ctrEl.textContent = `${ctrPct.toFixed(1)}%`; top10El.textContent = `${top10Pct.toFixed(2)}%`; let label, ragClass; if (score >= 70) { label = 'Good – money pages aren\'t holding Authority back.'; ragClass = 'status-green'; } else if (score >= 40) { label = 'Amber – money pages behaviour is the main Authority constraint.'; ragClass = 'status-amber'; } else { label = 'Red – poor CTR on money pages is strongly dragging Authority down.'; ragClass = 'status-red'; } if (statusEl) statusEl.textContent = label; if (cardEl) { cardEl.classList.remove('status-green', 'status-amber', 'status-red'); cardEl.classList.add(ragClass); // Apply RAG colors if (ragClass === 'status-green') { cardEl.style.borderLeft = '3px solid #10b981'; cardEl.style.background = '#f0fdf4'; } else if (ragClass === 'status-amber') { cardEl.style.borderLeft = '3px solid #f59e0b'; cardEl.style.background = '#fffbeb'; } else { cardEl.style.borderLeft = '3px solid #ef4444'; cardEl.style.background = '#fef2f2'; } } } // Create Top Pages section (full width, below pillar cards) createTopPagesSection(scores, saved); // Create Money Pages Performance section (right after Top Pages section) // Use setTimeout to ensure Top Pages section is inserted first setTimeout(() => { // IMPORTANT: Do NOT rebuild/overwrite the Money Pages HTML here. // That caused the top-level filter to "flip" a few seconds later (dropdown replaced), // removed the Suggested Top 10 container, and orphaned KPI elements (tiles reverting to —). // The canonical builder is `renderMoneyPagesSection()`. if (typeof renderMoneyPagesSection === 'function') { debugLog('✓ Money Pages: using renderMoneyPagesSection() (skip legacy HTML builder)', 'info'); } // Always render money pages section (will show "No data" if empty) // Get Money Pages metrics - prioritize passed scores, then saved data, then global let moneyPagesMetricsToRender = scores.moneyPagesMetrics || window.currentMoneyPagesMetrics || window.moneyPagesMetrics; if (!moneyPagesMetricsToRender && saved) { // Try multiple locations in saved data if (saved.scores && saved.scores.moneyPagesMetrics) { debugLog('Money Pages: Loading from saved.scores.moneyPagesMetrics', 'info'); moneyPagesMetricsToRender = saved.scores.moneyPagesMetrics; scores.moneyPagesMetrics = moneyPagesMetricsToRender; } else if (saved.moneyPagesMetrics) { debugLog('Money Pages: Loading from saved.moneyPagesMetrics', 'info'); moneyPagesMetricsToRender = saved.moneyPagesMetrics; scores.moneyPagesMetrics = moneyPagesMetricsToRender; } } // Store globally for other functions if (moneyPagesMetricsToRender) { window.currentMoneyPagesMetrics = moneyPagesMetricsToRender; window.moneyPagesMetrics = moneyPagesMetricsToRender; debugLog(`✓ Money Pages metrics stored globally: ${moneyPagesMetricsToRender.rows?.length || 0} rows`, 'success'); } debugLog(`Money Pages Metrics available: ${moneyPagesMetricsToRender ? 'yes' : 'no'}`, 'info'); if (moneyPagesMetricsToRender) { debugLog(`Money Pages: Rendering ${moneyPagesMetricsToRender.rows?.length || 0} money pages`, 'info'); if (moneyPagesMetricsToRender.rows && moneyPagesMetricsToRender.rows.length > 0) { debugLog(`Money Pages: First row sample: ${JSON.stringify(moneyPagesMetricsToRender.rows[0]).substring(0, 100)}...`, 'info'); } else { debugLog('⚠ Money Pages: moneyPagesMetrics exists but has no rows', 'warn'); } } else { debugLog('⚠ Money Pages: No metrics data available, will show "No data" message', 'warn'); debugLog(`Money Pages: scores.moneyPagesMetrics=${!!scores.moneyPagesMetrics}, saved.scores.moneyPagesMetrics=${!!(saved && saved.scores && saved.scores.moneyPagesMetrics)}, saved.moneyPagesMetrics=${!!(saved && saved.moneyPagesMetrics)}`, 'warn'); } // Ensure the Money Pages panel exists, then render the section. // IMPORTANT: `renderMoneyPagesSection()` is responsible for creating `#money-pages-section` // and the container IDs (top-level filter, charts, suggested top10, KPI selector). const ensureMoneyPagesRendered = (attempt = 0) => { const moneyPanel = document.querySelector('.aigeo-panel[data-panel="money"]'); if (!moneyPanel) { if (attempt < 20) { debugLog(`⚠ Money Pages panel not ready yet (attempt ${attempt + 1})`, 'warn'); setTimeout(() => ensureMoneyPagesRendered(attempt + 1), 100); } else { debugLog('✗ Money Pages panel not found after retries - cannot render Money Pages section', 'error'); } return; } (async () => { await renderMoneyPagesSection(moneyPagesMetricsToRender || null); // Wire the top-level filter AFTER the section HTML exists. if (typeof window.wireTopLevelFilter === 'function') { window.wireTopLevelFilter(); } // Wire KPI metric selector AFTER the section HTML exists. if (typeof window.wireMoneyKpiMetricSelector === 'function') { window.wireMoneyKpiMetricSelector(); } // Now that the Money Pages DOM exists, initialize dependent sections (Priority table, KPI tracker, etc). if (typeof window.__initMoneyPagesPanelAfterRender === 'function') { window.__initMoneyPagesPanelAfterRender(); } })(); }; ensureMoneyPagesRendered(0); // Define a post-render initializer that must only run after `renderMoneyPagesSection()` // has created the Money Pages DOM (tables/canvases/selectors). // It is invoked from inside `ensureMoneyPagesRendered()` after the section render completes. window.__initMoneyPagesPanelAfterRender = function __initMoneyPagesPanelAfterRender() { // Phase: Render Money Pages Priority Matrix & Action List const priorityCard = document.getElementById('money-pages-priority-card'); if (priorityCard) { if (window.moneyPagePriorityData && Array.isArray(window.moneyPagePriorityData) && window.moneyPagePriorityData.length > 0) { priorityCard.style.display = 'block'; // Initialize state for matrix filter window.moneyMatrixFilterState = window.moneyMatrixFilterState || { impact: null, diff: null }; // Domain-level authority actions (read-only; from Supabase domain_strength_snapshots via /api/actions) window.authorityActionRows = window.authorityActionRows || []; if (typeof window.refreshAuthorityActionsForMoneyPages !== 'function') { window.refreshAuthorityActionsForMoneyPages = async function refreshAuthorityActionsForMoneyPages() { try { const resp = await fetch('/api/actions'); const json = await resp.json(); const actions = resp.ok && json && json.status === 'ok' && Array.isArray(json.actions) ? json.actions : []; const toUpperLevel = (v, fallback) => { const s = String(v || '').toUpperCase(); return s === 'HIGH' || s === 'MEDIUM' || s === 'LOW' ? s : fallback; }; window.authorityActionRows = actions .filter(a => a && a.type === 'authority' && a.level === 'domain' && a.domain) .map(a => ({ url: `https://${String(a.domain).replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0]}`, title: a.title || 'Build domain authority', segmentType: 'authority', clicks: 0, impressions: 0, ctr: 0, avgPosition: null, impactLevel: toUpperLevel(a.impact, 'LOW'), difficultyLevel: toUpperLevel(a.difficulty, 'HIGH'), priorityLevel: toUpperLevel(a.priority, 'LOW'), _authorityMeta: { domain: a.domain, segment: a.segment || null, score: a.metrics && typeof a.metrics.domainStrengthScore === 'number' ? a.metrics.domainStrengthScore : null, band: a.metrics && typeof a.metrics.domainStrengthBand === 'string' ? a.metrics.domainStrengthBand : null } })); } catch (e) { // Non-fatal: keep existing authorityActionRows (or empty). window.authorityActionRows = window.authorityActionRows || []; } }; } // Helper function to get filtered pages based on current filters (defined before use) const getFilteredPages = () => { const typeFilter = document.getElementById('money-pages-type-filter')?.value || 'all'; const minImpr = Number(document.getElementById('money-pages-min-impr')?.value) || 0; const base = (window.moneyPagePriorityData || []).slice(); const authority = (window.authorityActionRows || []).slice(); let filtered = base.concat(authority); // Apply type filter if (typeFilter !== 'all') { filtered = filtered.filter(p => p.segmentType === typeFilter); } // Apply min impressions filter filtered = filtered.filter(p => p.segmentType === 'authority' || (p.impressions || 0) >= minImpr); return filtered; }; // Helper function to update summary strip, matrix, and table const updateMatrixAndTable = async () => { const typeFilter = document.getElementById('money-pages-type-filter')?.value || 'all'; const minImpr = Number(document.getElementById('money-pages-min-impr')?.value) || 0; // Get base data for counting (before type filter, but after min impressions) const base = (window.moneyPagePriorityData || []).slice(); const authority = (window.authorityActionRows || []).slice(); const allRows = base.concat(authority); // Apply min impressions filter for counting (but not type filter) const pagesForCounting = allRows.filter(p => p.segmentType === 'authority' || (p.impressions || 0) >= minImpr); // Update dropdown counts with filtered data (after min impressions, before type filter) updateMoneyPagesTypeFilterCounts(pagesForCounting); // Now get fully filtered pages (with type filter applied) const filteredPages = getFilteredPages(); debugLog(`Money Pages Update: Filtered to ${filteredPages.length} pages (type: ${typeFilter}, minImpr: ${minImpr})`, 'info'); // Reset pagination to page 1 when filters change window.moneyPagesPriorityCurrentPage = 1; // Re-render summary strip with filtered pages const pagesOnly = filteredPages.filter(p => p.segmentType !== 'authority'); const summaryStrip = document.getElementById('money-pages-summary-strip'); if (summaryStrip && typeof renderMoneyPagesSummaryStrip === 'function') { renderMoneyPagesSummaryStrip(pagesOnly, data || {}, summaryStrip); } // Re-render matrix with filtered pages const matrixEl = document.getElementById('money-pages-matrix'); if (matrixEl && typeof renderMoneyPagesMatrix === 'function') { const handleMatrixCellClick = async (impact, diff) => { // Toggle filter: if clicking the same cell, clear the filter const currentFilter = window.moneyMatrixFilterState; if (currentFilter && currentFilter.impact === impact && currentFilter.diff === diff) { // Same cell clicked - clear filter window.moneyMatrixFilterState = null; debugLog(`Money Pages Matrix: Cell clicked again - clearing filter`, 'info'); } else { // Different cell clicked - set new filter window.moneyMatrixFilterState = { impact, diff }; debugLog(`Money Pages Matrix: Cell clicked - Impact: ${impact}, Difficulty: ${diff}`, 'info'); } window.moneyPagesPriorityCurrentPage = 1; // Reset pagination when matrix cell clicked // Re-render matrix to show active state await updateMatrixAndTable(); }; renderMoneyPagesMatrix( pagesOnly, matrixEl, handleMatrixCellClick, window.moneyMatrixFilterState && window.moneyMatrixFilterState.impact && window.moneyMatrixFilterState.diff ? window.moneyMatrixFilterState : null ); } // Store filter state globally for pagination window.moneyPagesTypeFilter = typeFilter; window.moneyPagesMinImpr = minImpr; window.moneyPagesMatrixFilter = window.moneyMatrixFilterState; // Re-render Priority & Actions table (NOT the opportunity table renderer) if (typeof window.renderMoneyPagesPriorityTable === 'function') { console.log('[Money Pages Priority] updateMatrixAndTable: Re-rendering Priority & Actions table'); await window.renderMoneyPagesPriorityTable(filteredPages, { typeFilter: typeFilter, minImpr: minImpr, matrixFilter: window.moneyMatrixFilterState }); } }; // Render summary strip const summaryStrip = document.getElementById('money-pages-summary-strip'); if (summaryStrip) { renderMoneyPagesSummaryStrip(window.moneyPagePriorityData, data || {}, summaryStrip); } // Render matrix initially const matrixEl = document.getElementById('money-pages-matrix'); if (matrixEl && typeof renderMoneyPagesMatrix === 'function') { renderMoneyPagesMatrix( window.moneyPagePriorityData, matrixEl, async (impact, diff) => { window.moneyMatrixFilterState = { impact, diff }; debugLog(`Money Pages Matrix: Cell clicked - Impact: ${impact}, Difficulty: ${diff}`, 'info'); await updateMatrixAndTable(); }, null // No active filter initially ); } // Initialize filter state window.moneyPagesTypeFilter = 'all'; window.moneyPagesMinImpr = 0; window.moneyPagesMatrixFilter = null; // Render initial Priority & Actions table if (typeof window.renderMoneyPagesPriorityTable === 'function') { (async () => { await window.renderMoneyPagesPriorityTable(window.moneyPagePriorityData, { typeFilter: 'all', minImpr: 0, matrixFilter: null }); console.log('[Money Pages Priority] ✓ Initial Priority & Actions table rendered'); })(); } // Render Suggested (Top 10) cards once Money Pages DOM exists. if (typeof window.renderMoneyPagesSuggestedTop10 === 'function') { window.renderMoneyPagesSuggestedTop10(); } // Load authority actions async and refresh table (non-blocking). window.refreshAuthorityActionsForMoneyPages() .then(async () => { await updateMatrixAndTable(); }) .catch(() => { // ignore }); // Wire up filter controls (functions already defined above) const typeFilter = document.getElementById('money-pages-type-filter'); const minImprFilter = document.getElementById('money-pages-min-impr'); if (typeFilter) { typeFilter.addEventListener('change', () => { const selectedType = typeFilter.value; debugLog(`Money Pages Filter: Type changed to "${selectedType}"`, 'info'); // Store filter state globally for pagination window.moneyPagesTypeFilter = selectedType; // Clear matrix filter when type filter changes window.moneyMatrixFilterState = { impact: null, diff: null }; window.moneyPagesMatrixFilter = null; (async () => { await updateMatrixAndTable(); })(); }); } if (minImprFilter) { minImprFilter.addEventListener('change', () => { const minImpr = Number(minImprFilter.value) || 0; debugLog(`Money Pages Filter: Min impressions changed to ${minImpr}`, 'info'); // Store filter state globally for pagination window.moneyPagesMinImpr = minImpr; // Clear matrix filter when min impressions filter changes window.moneyMatrixFilterState = { impact: null, diff: null }; window.moneyPagesMatrixFilter = null; (async () => { await updateMatrixAndTable(); })(); }); } } else { // Show card but with "no data" message priorityCard.style.display = 'block'; const summaryStrip = document.getElementById('money-pages-summary-strip'); if (summaryStrip) { summaryStrip.innerHTML = '
No money pages data available. Run an audit to generate priority matrix data.
'; } const matrixEl = document.getElementById('money-pages-matrix'); if (matrixEl) { matrixEl.innerHTML = '
No data available
'; } const tbody = document.querySelector('#money-pages-priority-table tbody'); if (tbody) { tbody.innerHTML = 'No money pages data available. Run an audit to generate priority matrix data.'; } debugLog('⚠ Money Pages Priority Matrix: No data available', 'warn'); } } // Phase: Load and render 12-month KPI Tracker const propertyUrl = document.getElementById('propertyUrl')?.value || localStorage.getItem('gsc_property_url') || localStorage.getItem('last_property_url'); if (propertyUrl) { window.loadAuditHistoryAndRenderKpis(propertyUrl); // Wire up metric selector const metricSelect = document.getElementById('money-kpi-metric-select'); if (metricSelect) { metricSelect.addEventListener('change', (e) => { const metricKey = e.target.value; if (cachedAuditHistory) { // Reload full history with timeseries when metric changes const propertyUrl = document.getElementById('propertyUrl')?.value; if (propertyUrl) { window.loadAuditHistoryAndRenderKpis(propertyUrl); } else { // Fallback: use cached data but still need timeseries debugLog('⚠ Metric selector: No propertyUrl available, cannot reload timeseries', 'warn'); } } }); } } // Store queryPages globally for filtering // Use data parameter (searchData) first, then fallback to saved data if (data && data.queryPages) { window.currentQueryPages = data.queryPages; } else if (saved && saved.searchData && saved.searchData.queryPages) { window.currentQueryPages = saved.searchData.queryPages; } // Render behaviour KPIs and chart after a delay to ensure DOM and Chart.js are ready // Apply filters and update all sections with filtered data if (moneyPagesMetricsToRender) { setTimeout(() => { const queryPages = window.currentQueryPages || null; const moneyPagesMetrics = moneyPagesMetricsToRender; // Use saved behaviour data if available (from Supabase/localStorage) let savedBehaviour = null; if (moneyPagesMetrics && moneyPagesMetrics.behaviour) { savedBehaviour = moneyPagesMetrics.behaviour; debugLog(`🎯 Using saved behaviour data: score=${savedBehaviour.score}, impressions=${savedBehaviour.impressions}`, 'info'); } // Get filtered metrics based on current filters (defaults to 'ALL' on initial load) const filteredMetrics = getFilteredMoneyPagesMetrics(moneyPagesMetrics); if (filteredMetrics) { // Try to recalculate behaviour for filtered pages, but use saved if calculation fails const filteredRows = filteredMetrics.rows || []; let filteredBehaviour = null; if (window.computeMoneyPagesBehaviour && queryPages && filteredRows.length > 0) { filteredBehaviour = window.computeMoneyPagesBehaviour(queryPages, filteredRows, true); debugLog(`🎯 Recalculated behaviour for filtered pages: ${!!filteredBehaviour}, impressions=${filteredBehaviour?.impressions || 0}`, 'info'); } // Use saved behaviour as fallback if recalculation failed or returned no data if (!filteredBehaviour || !filteredBehaviour.impressions) { if (savedBehaviour && savedBehaviour.impressions) { debugLog(`🎯 Using saved behaviour as fallback (recalculation had no data)`, 'info'); filteredBehaviour = savedBehaviour; } } // Update all sections with filtered data renderMoneyPagesBehaviourKpis(filteredBehaviour, filteredMetrics, queryPages); updateMoneyPagesSummaryMetrics(moneyPagesMetrics); // Pass original metrics so function can apply filters internally updateMoneyPagesChartSummary(filteredMetrics); // Render chart with filtered data // Always re-render chart to ensure it uses current filters renderMoneyPagesCategoryChart(moneyPagesMetrics, 0); } else { // Fallback: render with unfiltered data if filtering fails renderMoneyPagesBehaviourKpis(savedBehaviour || moneyPagesMetrics.behaviour, moneyPagesMetrics, queryPages); if (!moneyPagesCategoryChart) { renderMoneyPagesCategoryChart(moneyPagesMetrics, 0); } } }, 200); } }; }, 100); // Remove existing scorecard table if it exists const existingScorecard = pillarCards.parentNode.querySelector('.scorecard-section'); if (existingScorecard) { existingScorecard.remove(); } // Function to render segment comparison table (shared by both createTopPagesSection functions) function renderSegmentComparisonTable(authorityBySegment, currentMode) { const segments = [ { key: 'all', label: 'All pages', data: authorityBySegment.all }, { key: 'nonEducation', label: 'Exclude education (blogs / free course)', data: authorityBySegment.nonEducation }, { key: 'money', label: 'Money pages only', data: authorityBySegment.money } ]; let tableHtml = `

Segment overview (CTR & ranking)

`; segments.forEach(({ key, label, data }, idx) => { const isActive = key === currentMode; const siteCtr = data?.siteCtr || 0; const top10Ctr = data?.top10Ctr || 0; const avgPosition = data?.avgPosition || 0; const top10Share = (data?.top10Share || 0) * 100; const behaviourScore = data?.behaviour || 0; const rankingScore = data?.ranking || 0; tableHtml += ` `; }); tableHtml += `
Segment Site CTR Top-10 CTR Avg pos. Top-10 share Behaviour Ranking
${label} ${isActive ? 'current' : ''} ${siteCtr != null ? siteCtr.toFixed(1) : 'N/A'}% ${top10Ctr != null ? top10Ctr.toFixed(1) : 'N/A'}% ${avgPosition != null ? avgPosition.toFixed(1) : 'N/A'} ${top10Share != null ? top10Share.toFixed(1) : 'N/A'}% ${behaviourScore != null ? Math.round(behaviourScore) : 'N/A'} ${rankingScore != null ? Math.round(rankingScore) : 'N/A'}
`; return tableHtml; } // NOTE: Duplicate populateMoneyPagesAiCitations function removed - using the one at line ~30217 that takes 'rows' parameter // Function to create Top Pages section (full width, below pillar cards) function createTopPagesSection(scores, saved) { // Get Authority segment data from current scores (latest snapshot, not historical) // This uses the most recent audit data, not historical Supabase data const authorityObj = scores?.authority; let authorityBySegment = (typeof authorityObj === 'object' && authorityObj !== null) ? authorityObj.bySegment : null; // If no segment data in scores, try to get from saved audit (latest audit data from localStorage) if (!authorityBySegment && saved) { const savedScores = saved.scores; if (savedScores && savedScores.authority) { const savedAuthorityObj = savedScores.authority; if (typeof savedAuthorityObj === 'object' && savedAuthorityObj !== null) { authorityBySegment = savedAuthorityObj.bySegment || null; debugLog('📊 Top Pages: Using Authority segment data from saved audit (latest snapshot)', 'info'); } } } if (!authorityBySegment) { debugLog('⚠ No Authority segment data available for Top Pages table. This requires GSC queryPages data from your most recent audit.', 'warn'); // Still create the section but show a helpful message - don't return early } // Remove existing top pages section if it exists const existingTopPages = document.getElementById('authority-top-pages-section'); if (existingTopPages) { existingTopPages.remove(); } // Create new section const topPagesSection = document.createElement('div'); topPagesSection.id = 'authority-top-pages-section'; topPagesSection.className = 'section-break'; topPagesSection.style.marginTop = '2rem'; topPagesSection.style.marginBottom = '2rem'; // Get current mode from Authority card toggle (default to 'all') let currentMode = 'all'; const authorityCard = Array.from(document.querySelectorAll('.pillar-card')).find(card => { const h3 = card.querySelector('h3'); return h3 && h3.textContent === 'Authority'; }); if (authorityCard && authorityCard._authorityMode) { currentMode = authorityCard._authorityMode; } // Get top pages for current mode const topPages = currentMode === 'all' ? (authorityBySegment?.all?.topPages || []) : currentMode === 'nonEducation' ? (authorityBySegment?.nonEducation?.topPages || []) : (authorityBySegment?.money?.topPages || []); const segmentLabel = currentMode === 'all' ? 'All pages' : currentMode === 'nonEducation' ? 'Exclude education (blogs / free course)' : 'Money pages only'; // Get segment metrics for summary display const getSegmentSummary = (mode) => { if (!authorityBySegment || !authorityBySegment[mode]) return null; const segmentData = authorityBySegment[mode]; return { behaviour: segmentData.behaviour || 0, ranking: segmentData.ranking || 0, total: segmentData.total || segmentData.score || 0 }; }; const currentSummary = getSegmentSummary(currentMode); const rag = currentSummary ? getRAGStatus(currentSummary.total) : { status: 'amber', label: 'N/A' }; // Get date range for display const dateRange = parseInt(localStorage.getItem('gsc_date_range') || '30', 10); const dateRangeText = dateRange === 30 ? '30 days' : dateRange === 60 ? '60 days' : dateRange === 90 ? '90 days' : dateRange === 120 ? '120 days' : dateRange === 180 ? '180 days' : dateRange === 365 ? '365 days' : dateRange === 540 ? '540 days' : `${dateRange} days`; // Get brand queries for mini-table const topQueries = saved?.searchData?.topQueries || []; const brandQueries = topQueries .filter(q => isBrandQuery(q.query || '')) .sort((a, b) => (b.impressions || 0) - (a.impressions || 0)) .slice(0, 10); // Create section HTML with improved styling and pastel background topPagesSection.innerHTML = `

Authority - Behaviour & Ranking

Behaviour: Measures click-through rate (CTR) performance. Combines Overall CTR (50% weight) for all ranking search terms and Top-10 Ranked Search Terms CTR (50% weight) for queries ranking in positions 1-10. Indicates how well your titles and descriptions convert impressions to clicks. Data source: Google Search Console query+page metrics.

Ranking: Measures search visibility and position quality. Combines Average Position Score (50% weight) and Top-10 Impression Share (50% weight). Shows how high you rank on average and what percentage of impressions appear in positions 1-10. Data source: Google Search Console query+page metrics.

${currentSummary ? `
Segment Summary: ${segmentLabel}
${formatComponentScore('Behaviour', currentSummary.behaviour)} ${formatComponentScore('Ranking', currentSummary.ranking)}
${Math.round(currentSummary.total)} ${rag.label}
` : `
Current Segment: ${segmentLabel}
`} ${authorityBySegment ? `
View:
` : ''} ${authorityBySegment ? `
${renderSegmentComparisonTable(authorityBySegment, currentMode)}
` : ''}
${renderFullWidthTopPagesTable(topPages, segmentLabel, dateRangeText)}
${authorityBySegment && authorityBySegment[currentMode] ? `
${renderRecommendationsTable(currentMode, { siteCtr: authorityBySegment[currentMode].siteCtr || 0, top10Ctr: authorityBySegment[currentMode].top10Ctr || 0, avgPosition: authorityBySegment[currentMode].avgPosition || 0, top10Share: authorityBySegment[currentMode].top10Share || 0, behaviourScore: authorityBySegment[currentMode].behaviour || 0, rankingScore: authorityBySegment[currentMode].ranking || 0 }, segmentLabel, dateRangeText)}
` : ''} ${brandQueries.length > 0 ? `

Top Branded Queries

Branded search queries (e.g., "Alan Ranger Photography") with CTR and position metrics.

${brandQueries.map((q, idx) => { const ctr = q.impressions > 0 ? ((q.clicks || 0) / q.impressions * 100) : 0; const ctrColor = ctr >= 25 ? '#10b981' : ctr >= 10 ? '#f59e0b' : '#ef4444'; const posColor = (q.position || 0) <= 3 ? '#10b981' : (q.position || 0) <= 5 ? '#f59e0b' : '#ef4444'; return ` `; }).join('')}
Query Impressions Clicks CTR Position
${(q.query || '').replace(//g, '>')} ${(q.impressions || 0).toLocaleString()} ${(q.clicks || 0).toLocaleString()} ${ctr.toFixed(1)}% ${(q.position || 0).toFixed(1)}
` : ''}
`; // Insert into Authority panel instead of Overview const authorityPanel = document.querySelector('.aigeo-panel[data-panel="authority"]'); if (authorityPanel) { // Clear any existing content const existing = document.getElementById('authority-top-pages-section'); if (existing) existing.remove(); authorityPanel.appendChild(topPagesSection); debugLog('✓ Authority section inserted into Authority panel', 'success'); } else { // Fallback: insert after pillar cards (old behavior) pillarCards.parentNode.insertBefore(topPagesSection, pillarCards.nextSibling); debugLog('✓ Authority section inserted after pillar cards (fallback)', 'info'); } // Store authorityBySegment globally so updateTopPagesSection can access it window.authorityBySegment = authorityBySegment; // Attach toggle button handlers setTimeout(() => { ['all', 'nonEducation', 'money'].forEach(mode => { const btn = document.getElementById(`top-pages-mode-${mode}`); if (btn) { // Remove existing listeners by cloning const newBtn = btn.cloneNode(true); btn.parentNode.replaceChild(newBtn, btn); newBtn.addEventListener('click', () => { window.currentAuthorityMode = mode; if (window.updateTopPagesSection) { window.updateTopPagesSection(mode); } // Also update Authority pillar card if it exists const authorityCard = Array.from(document.querySelectorAll('.pillar-card')).find(card => { const h3 = card.querySelector('h3'); return h3 && h3.textContent === 'Authority'; }); if (authorityCard && authorityCard._updateAuthorityDisplay) { authorityCard._authorityMode = mode; authorityCard._updateAuthorityDisplay(); // Update Authority pillar toggle buttons const modeId = authorityCard._modeId; ['all', 'nonEducation', 'money'].forEach(m => { const authBtn = document.getElementById(`${modeId}-${m}`); if (authBtn) { if (m === mode) { authBtn.style.background = '#10b981'; authBtn.style.color = 'white'; } else { authBtn.style.background = 'white'; authBtn.style.color = '#666'; } } }); } }); } }); }, 100); // Store update function globally so Authority mode toggle can call it window.updateTopPagesSection = function(mode) { // Get fresh authorityBySegment from global or try to get from current scores let segData = window.authorityBySegment; if (!segData) { // Try to get from current scores const authorityObj = scores?.authority; segData = (typeof authorityObj === 'object' && authorityObj !== null) ? authorityObj.bySegment : null; } const topPages = mode === 'all' ? (segData?.all?.topPages || []) : mode === 'nonEducation' ? (segData?.nonEducation?.topPages || []) : (segData?.money?.topPages || []); const segmentLabel = mode === 'all' ? 'All pages' : mode === 'nonEducation' ? 'Exclude education (blogs / free course)' : 'Money pages only'; debugLog(`📊 Top Pages: Updating to segment "${mode}", found ${topPages.length} pages`, 'info'); // Update segment label const labelEl = document.getElementById('top-pages-segment-label'); if (labelEl) labelEl.textContent = segmentLabel; // Update summary if available const getSegmentSummary = (m) => { if (!segData || !segData[m]) return null; const segmentData = segData[m]; return { behaviour: segmentData.behaviour || 0, ranking: segmentData.ranking || 0, total: segmentData.total || segmentData.score || 0 }; }; const summary = getSegmentSummary(mode); const summaryDiv = document.getElementById('top-pages-segment-summary'); if (summary && summaryDiv) { const rag = getRAGStatus(summary.total); summaryDiv.innerHTML = ` ${formatComponentScore('Behaviour', summary.behaviour)} ${formatComponentScore('Ranking', summary.ranking)} `; // Update RAG badge and score const ragBadge = summaryDiv.parentElement.querySelector('.rag-badge'); const scoreSpan = summaryDiv.parentElement.querySelector('span[style*="font-size: 1.5rem"]'); if (ragBadge) { ragBadge.className = `rag-badge ${rag.status}`; ragBadge.textContent = rag.label; } if (scoreSpan) { scoreSpan.textContent = Math.round(summary.total); scoreSpan.style.color = rag.status === 'green' ? '#10b981' : rag.status === 'amber' ? '#f59e0b' : '#ef4444'; } } // Update toggle buttons ['all', 'nonEducation', 'money'].forEach(m => { const btn = document.getElementById(`top-pages-mode-${m}`); if (btn) { if (m === mode) { btn.style.background = '#10b981'; btn.style.color = 'white'; btn.style.fontWeight = '600'; } else { btn.style.background = 'white'; btn.style.color = '#666'; btn.style.fontWeight = '400'; } } }); // Update comparison table const comparisonDiv = document.getElementById('top-pages-comparison-table'); if (comparisonDiv && segData) { comparisonDiv.innerHTML = renderSegmentComparisonTable(segData, mode); } // Get date range for display const dateRange = parseInt(localStorage.getItem('gsc_date_range') || '30', 10); const dateRangeText = dateRange === 30 ? '30 days' : dateRange === 60 ? '60 days' : dateRange === 90 ? '90 days' : dateRange === 120 ? '120 days' : dateRange === 180 ? '180 days' : dateRange === 365 ? '365 days' : dateRange === 540 ? '540 days' : `${dateRange} days`; // Update table (reset sort when switching segments) window.topPagesData = topPages; window.topPagesSortColumn = null; window.topPagesSortDirection = 'desc'; const tableContainer = document.getElementById('top-pages-table-container'); if (tableContainer) { tableContainer.innerHTML = renderFullWidthTopPagesTable(topPages, segmentLabel, dateRangeText); attachCopyButtonHandler(); attachSortHandlers(); } // Update recommendations table const recommendationsContainer = document.getElementById('top-pages-recommendations-container'); if (recommendationsContainer && segData && segData[mode]) { const segmentLabel = mode === 'all' ? 'All pages' : mode === 'nonEducation' ? 'Exclude education (blogs / free course)' : 'Money pages only'; const dateRange = parseInt(localStorage.getItem('gsc_date_range') || '30', 10); const dateRangeText = dateRange === 30 ? '30 days' : dateRange === 60 ? '60 days' : dateRange === 90 ? '90 days' : dateRange === 120 ? '120 days' : dateRange === 180 ? '180 days' : dateRange === 365 ? '365 days' : dateRange === 540 ? '540 days' : `${dateRange} days`; recommendationsContainer.innerHTML = renderRecommendationsTable(mode, { siteCtr: segData[mode].siteCtr || 0, top10Ctr: segData[mode].top10Ctr || 0, avgPosition: segData[mode].avgPosition || 0, top10Share: segData[mode].top10Share || 0, behaviourScore: segData[mode].behaviour || 0, rankingScore: segData[mode].ranking || 0 }, segmentLabel, dateRangeText); } }; // Helper to attach sort handlers after table is in DOM function attachSortHandlers() { setTimeout(() => { ['ctr', 'impressions', 'clicks', 'position'].forEach(col => { const th = document.getElementById(`sort-${col}`); if (th) { // Remove existing listeners by cloning const newTh = th.cloneNode(true); th.parentNode.replaceChild(newTh, th); newTh.addEventListener('click', () => { if (window.handleSort) { window.handleSort(col); } }); } }); }, 50); } // Attach initial handlers after section is created setTimeout(() => { attachCopyButtonHandler(); attachSortHandlers(); }, 150); function attachCopyButtonHandler() { setTimeout(() => { const copyBtn = document.getElementById('top-pages-copy-urls'); if (copyBtn) { // Remove existing listener const newCopyBtn = copyBtn.cloneNode(true); copyBtn.parentNode.replaceChild(newCopyBtn, copyBtn); newCopyBtn.addEventListener('click', async () => { const currentMode = window.currentAuthorityMode || 'all'; const currentTopPages = currentMode === 'all' ? (authorityBySegment?.all?.topPages || []) : currentMode === 'nonEducation' ? (authorityBySegment?.nonEducation?.topPages || []) : (authorityBySegment?.money?.topPages || []); const text = currentTopPages.map(p => p.url).join('\n'); try { await navigator.clipboard.writeText(text); newCopyBtn.textContent = 'Copied!'; newCopyBtn.style.color = '#10b981'; setTimeout(() => { newCopyBtn.textContent = 'Copy URLs'; newCopyBtn.style.color = '#666'; }, 2000); } catch (err) { console.error('Failed to copy URLs:', err); newCopyBtn.textContent = 'Copy failed'; setTimeout(() => { newCopyBtn.textContent = 'Copy URLs'; }, 2000); } }); } }, 0); } } // Add pillar scorecard table // Use current audit data (from function parameters) instead of stale localStorage data const auditTimestamp = saved?.timestamp; // Define schemaAuditData in scope accessible to scorecard table const schemaAuditData = schemaAudit || saved?.schemaAudit; // Fetch historical scores for the last date in chart range to ensure scorecard matches chart // This fixes the issue where scorecard shows latest audit score but chart shows historical score let historicalScoresForLastDate = {}; const propertyUrlForHistorical = document.getElementById('propertyUrl')?.value || data?.propertyUrl || propertyUrl || ''; if (propertyUrlForHistorical) { try { // Get the last date from timeseries or maps let lastChartDate = null; if (data && data.timeseries && Array.isArray(data.timeseries) && data.timeseries.length > 0) { lastChartDate = data.timeseries[data.timeseries.length - 1].date; } else if (window.lastGscTimeseriesDate) { lastChartDate = window.lastGscTimeseriesDate; } else if (window.authorityMap && window.authorityMap.size > 0) { const authorityDates = Array.from(window.authorityMap.keys()).sort(); lastChartDate = authorityDates[authorityDates.length - 1]; } if (lastChartDate) { debugLog(`[Scorecard] Fetching historical scores for last chart date: ${lastChartDate}`, 'info'); const historicalResponse = await fetch(apiUrl(`/api/supabase/get-audit-history?propertyUrl=${encodeURIComponent(propertyUrlForHistorical)}&startDate=${lastChartDate}&endDate=${lastChartDate}`)); if (historicalResponse.ok) { const historicalData = await historicalResponse.json(); if (historicalData.status === 'ok' && historicalData.data && Array.isArray(historicalData.data) && historicalData.data.length > 0) { // Get the latest audit for this date (in case of multiple audits per day) const auditsForDate = historicalData.data.filter(r => r.date === lastChartDate); if (auditsForDate.length > 0) { // Sort by timestamp/updated_at to get the latest auditsForDate.sort((a, b) => { const aTime = a.updated_at || a.timestamp || 0; const bTime = b.updated_at || b.timestamp || 0; return bTime - aTime; }); const latestAudit = auditsForDate[0]; historicalScoresForLastDate = { authority: latestAudit.authorityScore, contentSchema: latestAudit.contentSchemaScore, localEntity: latestAudit.localEntityScore, serviceArea: latestAudit.serviceAreaScore, visibility: latestAudit.visibilityScore }; debugLog(`[Scorecard] Found historical scores for ${lastChartDate}: Authority=${historicalScoresForLastDate.authority}, Content/Schema=${historicalScoresForLastDate.contentSchema}`, 'info'); } } } } } catch (e) { debugLog(`[Scorecard] Error fetching historical scores: ${e.message}`, 'warn'); } } // Get last GSC data date from current data (for Authority, Visibility, and Brand & Entity) // CRITICAL: Always fetch fresh from Supabase to get the actual last timeseries date // data.timeseries may be stale (from localStorage), so we can't rely on it for the Data Date let gscLastDate = null; // Always fetch fresh from Supabase to ensure we have the latest date const propertyUrl = document.getElementById('propertyUrl')?.value || data?.propertyUrl || ''; if (propertyUrl) { try { // Try window first (if renderTrendChart already ran and set it) if (window.lastGscTimeseriesDate) { gscLastDate = window.lastGscTimeseriesDate; debugLog(`Using last GSC timeseries date from window: ${gscLastDate}`, 'info'); } else { // Fetch from Supabase API to get the last timeseries date const endDate = new Date().toISOString().split('T')[0]; const startDate = new Date(); startDate.setDate(startDate.getDate() - 30); // Last 30 days const startDateStr = startDate.toISOString().split('T')[0]; debugLog(`Fetching last GSC timeseries date from Supabase for Data Date...`, 'info'); const timeseriesResponse = await fetch(apiUrl(`/api/supabase/get-audit-history?propertyUrl=${encodeURIComponent(propertyUrl)}&startDate=${startDateStr}&endDate=${endDate}`)); if (timeseriesResponse.ok) { const timeseriesData = await timeseriesResponse.json(); if (timeseriesData.status === 'ok' && timeseriesData.timeseries && Array.isArray(timeseriesData.timeseries) && timeseriesData.timeseries.length > 0) { const lastPoint = timeseriesData.timeseries[timeseriesData.timeseries.length - 1]; if (lastPoint && lastPoint.date) { gscLastDate = lastPoint.date; debugLog(`✓ Using last GSC timeseries date from Supabase API: ${gscLastDate}`, 'success'); } else { debugLog(`⚠ Supabase timeseries response missing date in last point`, 'warn'); } } else { debugLog(`⚠ Supabase timeseries response missing data: status=${timeseriesData.status}, hasTimeseries=${!!timeseriesData.timeseries}`, 'warn'); } } else { debugLog(`⚠ Failed to fetch timeseries from Supabase: ${timeseriesResponse.status}`, 'warn'); } } } catch (e) { debugLog(`Error fetching GSC date: ${e.message}`, 'warn'); } } // Fallback to data.timeseries only if Supabase fetch failed if (!gscLastDate && data && data.timeseries && Array.isArray(data.timeseries) && data.timeseries.length > 0) { const lastTimeseriesPoint = data.timeseries[data.timeseries.length - 1]; if (lastTimeseriesPoint && lastTimeseriesPoint.date) { gscLastDate = lastTimeseriesPoint.date; debugLog(`Using last GSC timeseries date from data (fallback): ${gscLastDate}`, 'warn'); } } // Fallback to global maps, but filter to only dates <= last timeseries date if available if (!gscLastDate) { // Try to get from maps, but only if we can verify it's <= last timeseries date // For now, use the latest from maps but log a warning if (window.visibilityMap && window.visibilityMap.size > 0) { const visibilityDates = Array.from(window.visibilityMap.keys()).sort(); const latestMapDate = visibilityDates[visibilityDates.length - 1]; // Only use if we don't have a timeseries date to compare against // If we have window.lastGscTimeseriesDate, use that instead if (window.lastGscTimeseriesDate) { gscLastDate = window.lastGscTimeseriesDate; debugLog(`Using last GSC timeseries date from window (filtered): ${gscLastDate}`, 'info'); } else { gscLastDate = latestMapDate; debugLog(`Using last GSC date from visibilityMap (fallback): ${gscLastDate}`, 'warn'); } } else if (window.authorityMap && window.authorityMap.size > 0) { const authorityDates = Array.from(window.authorityMap.keys()).sort(); const latestMapDate = authorityDates[authorityDates.length - 1]; if (window.lastGscTimeseriesDate) { gscLastDate = window.lastGscTimeseriesDate; debugLog(`Using last GSC timeseries date from window (filtered): ${gscLastDate}`, 'info'); } else { gscLastDate = latestMapDate; debugLog(`Using last GSC date from authorityMap (fallback): ${gscLastDate}`, 'warn'); } } } // Final fallback to date from current data if available if (!gscLastDate && data && data.date) { gscLastDate = data.date; debugLog(`Using GSC data date (fallback): ${gscLastDate}`, 'info'); } // If still no date, try to get it from saved audit data if (!gscLastDate && saved && saved.searchData && saved.searchData.date) { gscLastDate = saved.searchData.date; debugLog(`Using saved GSC data date (fallback): ${gscLastDate}`, 'info'); } // Format date for display function formatDataDate(dateStr) { if (!dateStr) return ''; const date = new Date(dateStr + 'T00:00:00'); // Add time to avoid timezone issues return date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); } // Format timestamp for display function formatTimestamp(timestamp) { if (!timestamp) return ''; const date = new Date(timestamp); return date.toLocaleString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } // Get data date for each pillar function getPillarDataDate(pillarKey) { if (pillarKey === 'authority' || pillarKey === 'visibility' || pillarKey === 'brandOverlay') { // GSC-based pillars - use last GSC data date (Brand & Entity uses GSC query data) return gscLastDate ? formatDataDate(gscLastDate) : (auditTimestamp ? formatTimestamp(auditTimestamp) : ''); } else { // Business Profile and schema audit - use audit timestamp return auditTimestamp ? formatTimestamp(auditTimestamp) : ''; } } const scorecardTable = document.createElement('div'); scorecardTable.className = 'scorecard-section'; scorecardTable.innerHTML = `

Pillar Scorecard

${(() => { // Phase 3: Insert Brand & Entity row after Authority const orderedPillars = getOrderedPillars(scores); const brandOverlay = scores.brandOverlay; const brandRowIndex = orderedPillars.findIndex(([key]) => key === 'authority'); // Insert Brand row after Authority if it exists if (brandRowIndex >= 0 && brandOverlay) { orderedPillars.splice(brandRowIndex + 1, 0, ['brandOverlay', brandOverlay.score || 0]); } // Use historical scores for the last date in chart range (if available) // This ensures scorecard matches what's shown in the trend chart // Priority: 1) Fetched historical scores, 2) Window maps, 3) Current scores const lastChartDate = window.latestAuditDateStr || window.lastGscTimeseriesDate || (data && data.timeseries && data.timeseries.length > 0 ? data.timeseries[data.timeseries.length - 1].date : null); if (lastChartDate) { // Override scores with historical values if available orderedPillars.forEach(([key, currentScore], idx) => { let historicalScore = null; // First, try fetched historical scores if (key === 'authority' && historicalScoresForLastDate.authority !== null && historicalScoresForLastDate.authority !== undefined) { historicalScore = historicalScoresForLastDate.authority; } else if (key === 'contentSchema' && historicalScoresForLastDate.contentSchema !== null && historicalScoresForLastDate.contentSchema !== undefined) { historicalScore = historicalScoresForLastDate.contentSchema; } else if (key === 'localEntity' && historicalScoresForLastDate.localEntity !== null && historicalScoresForLastDate.localEntity !== undefined) { historicalScore = historicalScoresForLastDate.localEntity; } else if (key === 'serviceArea' && historicalScoresForLastDate.serviceArea !== null && historicalScoresForLastDate.serviceArea !== undefined) { historicalScore = historicalScoresForLastDate.serviceArea; } else if (key === 'visibility' && historicalScoresForLastDate.visibility !== null && historicalScoresForLastDate.visibility !== undefined) { historicalScore = historicalScoresForLastDate.visibility; } // Fallback to window maps if fetched scores not available else if (key === 'authority' && window.authorityMap && window.authorityMap.has(lastChartDate)) { historicalScore = window.authorityMap.get(lastChartDate); } else if (key === 'contentSchema' && window.contentSchemaMap && window.contentSchemaMap.has(lastChartDate)) { historicalScore = window.contentSchemaMap.get(lastChartDate); } else if (key === 'localEntity' && window.localEntityMap && window.localEntityMap.has(lastChartDate)) { historicalScore = window.localEntityMap.get(lastChartDate); } else if (key === 'serviceArea' && window.serviceAreaMap && window.serviceAreaMap.has(lastChartDate)) { historicalScore = window.serviceAreaMap.get(lastChartDate); } else if (key === 'visibility' && window.visibilityMap && window.visibilityMap.has(lastChartDate)) { historicalScore = window.visibilityMap.get(lastChartDate); } if (historicalScore !== null && historicalScore !== undefined) { orderedPillars[idx][1] = historicalScore; debugLog(`[Scorecard] Using historical ${key} score (${historicalScore}) for ${lastChartDate} instead of current (${currentScore})`, 'info'); } else { debugLog(`[Scorecard] No historical ${key} score found for ${lastChartDate}, using current (${currentScore})`, 'info'); } }); } else { debugLog(`[Scorecard] No last chart date available, using current audit scores`, 'info'); } return orderedPillars; })().map(([key, score], index) => { const rag = getRAGStatus(score); // Define pillar colors for scorecard table (matching charts) const scorecardPillarColors = { localEntity: 'rgba(147, 51, 234, 1)', // Purple serviceArea: '#00FFFF', // Cyan authority: '#99004C', // Dark pink/magenta visibility: 'rgba(37, 99, 235, 1)', // Blue contentSchema: 'rgba(107, 114, 128, 1)' // Grey }; const pillarColor = scorecardPillarColors[key] || '#666'; // Build Content/Schema description with schema audit data let contentSchemaDesc = 'Quality and completeness of structured data markup across your domain.
AI Importance: Structured data is the primary way AI understands your content. Schema markup directly feeds AI systems, enabling them to extract facts, relationships, and context for use in AI Overviews and answer generation.
Diversity: Measures how varied your structured content is – different schema types (Article, Event, Course, FAQPage, HowTo, Product, Review, VideoObject, ImageObject, ItemList, LocalBusiness) and formats across the site. A broader mix of types gives AI more reliable, context-rich signals for understanding what you do and for building accurate summaries.
Data Source:Live data from schema audit (crawls actual website pages for JSON-LD markup).
Calculation: Weighted score based on Foundation schemas (30%), Rich Result eligibility (35%), Coverage (20%), and Type Diversity (15%).'; if (schemaAudit && schemaAudit.status === 'ok' && schemaAudit.data) { const schemaData = schemaAudit.data; const { coverage, totalPages, pagesWithSchema, missingSchemaCount, missingSchemaPages, schemaTypes } = schemaData; // Handle pagesWithSchema - it might be an array or a number const pagesWithSchemaCount = Array.isArray(pagesWithSchema) ? pagesWithSchema.length : (typeof pagesWithSchema === 'number' ? pagesWithSchema : 0); const totalPagesCount = typeof totalPages === 'number' ? totalPages : (Array.isArray(schemaData.pages) ? schemaData.pages.length : 0); let coverageValue = 'N/A'; if (typeof coverage === 'number' && !isNaN(coverage)) { coverageValue = coverage.toFixed(1); } else if (totalPagesCount > 0) { coverageValue = ((pagesWithSchemaCount / totalPagesCount) * 100).toFixed(1); } // Calculate foundation schemas - PRIORITY: use schemaData.foundation object first (most reliable) // foundation object has {Organization: true, Person: true, WebSite: true, BreadcrumbList: true} const allTypes = new Set(); // First, use foundation object if available (most reliable source) if (schemaData.foundation && typeof schemaData.foundation === 'object') { Object.keys(schemaData.foundation).forEach(type => { if (schemaData.foundation[type] === true) { allTypes.add(type); } }); } // Also add types from allDetectedTypes if available (for complete type list) if (schemaData.allDetectedTypes && Array.isArray(schemaData.allDetectedTypes)) { schemaData.allDetectedTypes.forEach(type => { if (type) allTypes.add(type); }); } // Fallback: collect from schemaTypes array (contains all types, sorted by count) // IMPORTANT: schemaTypes might be the pages array, so filter carefully if (schemaTypes && Array.isArray(schemaTypes) && allTypes.size === 0) { schemaTypes.forEach(item => { // Skip page objects (have 'url' property) - these are NOT schema types if (item && typeof item === 'object' && item.url) { return; // Skip page objects } // Only process valid schema type objects (not page objects with url property) if (item && typeof item === 'object' && item.type && typeof item.type === 'string' && !item.url) { allTypes.add(item.type); } else if (typeof item === 'string') { allTypes.add(item); } }); } const foundationTypes = ['Organization', 'Person', 'WebSite', 'BreadcrumbList']; const foundationPresent = foundationTypes.filter(type => allTypes.has(type)).length; // Count rich result types const richEligibleCount = Object.values(schemaData.richEligible || {}).filter(eligible => eligible === true).length; const uniqueTypesCount = allTypes.size; const richResultTypesList = ['Article', 'Event', 'FAQPage', 'Product', 'LocalBusiness', 'Course', 'Review', 'HowTo', 'VideoObject', 'ImageObject', 'ItemList']; const richTypesPresent = richResultTypesList.filter(type => allTypes.has(type)); contentSchemaDesc += ` Data Checked: Foundation schemas: ${foundationPresent}/4 (${foundationTypes.filter(t => allTypes.has(t)).join(', ') || 'none'}), Rich result types: ${richEligibleCount}/${richResultTypesList.length} eligible, Coverage: ${coverageValue}% (${pagesWithSchemaCount}/${totalPagesCount} pages), Type diversity: ${richTypesPresent.length}/${richResultTypesList.length} rich result types present (${richTypesPresent.join(', ') || 'none'}), ${uniqueTypesCount} total unique types across the site. `; // Display schema types - use allDetectedTypes if available, otherwise try schemaTypes (but filter out pages) let schemaTypesToDisplay = []; if (schemaData.allDetectedTypes && Array.isArray(schemaData.allDetectedTypes)) { schemaTypesToDisplay = schemaData.allDetectedTypes.slice(0, 15); } else if (schemaTypes && Array.isArray(schemaTypes)) { // Filter out page objects (those with 'url' property) - these are NOT schema types schemaTypesToDisplay = schemaTypes .filter(t => { // Skip null/undefined if (!t) return false; // Skip page objects (have 'url' property) - these are pages, not types! if (typeof t === 'object' && ('url' in t || ('title' in t && 'metaDescription' in t))) return false; // Only keep valid type objects or strings return typeof t === 'string' || (typeof t === 'object' && t.type && typeof t.type === 'string' && !t.url); }) .slice(0, 15); } if (schemaTypesToDisplay.length > 0) { // Convert to display strings const topTypes = schemaTypesToDisplay .map(t => { if (typeof t === 'string' && t.trim()) { return t.trim(); } else if (t && typeof t === 'object' && t.type && typeof t.type === 'string') { return `${t.type}${t.count ? ` (${t.count})` : ''}`; } else { // Skip objects without a valid type property - don't convert to [object Object] return null; } }) .filter(t => t !== null && t !== undefined && t !== '' && typeof t === 'string' && !t.includes('[object Object]')) .join(', '); if (topTypes) { contentSchemaDesc += `Schema types found: ${topTypes}${schemaTypesToDisplay.length > 15 ? '...' : ''}. `; } } if (schemaData.missingTypes && Array.isArray(schemaData.missingTypes) && schemaData.missingTypes.length > 0) { // Ensure all items are strings - properly handle objects const missingTypesList = schemaData.missingTypes .map(t => { if (typeof t === 'string') return t; if (t && typeof t === 'object' && t.type && typeof t.type === 'string') return t.type; // Skip objects that can't be converted return null; }) .filter(t => t !== null && t !== undefined && typeof t === 'string'); if (missingTypesList.length > 0) { contentSchemaDesc += `Missing foundation types: ${missingTypesList.join(', ')}. `; } } const richTypes = Object.entries(schemaData.richEligible || {}) .filter(([type, eligible]) => eligible) .map(([type]) => type); if (richTypes.length > 0) { contentSchemaDesc += `Rich result eligible: ${richTypes.join(', ')}. `; } // Check if Review schema is detected (even if not in top 10 types) const hasReviewSchema = allTypes.has('Review'); if (hasReviewSchema) { // Try to get count from schemaTypes array first, otherwise check schemaData for actual count let reviewCount = schemaTypes?.find(t => t.type === 'Review')?.count || 0; // If not in top 10, check if we have schemaData with all types info if (reviewCount === 0 && schemaData.schemaTypes) { // schemaTypes in response is top 10, but we need to check if Review exists // Since it's in allTypes, it exists, but we don't have the exact count // Just indicate it's detected without showing 0 contentSchemaDesc += `Review schema detected - matches GSC review snippets data. `; } else if (reviewCount > 0) { contentSchemaDesc += `Review schema detected (${reviewCount} instances) - matches GSC review snippets data. `; } else { contentSchemaDesc += `Review schema detected - matches GSC review snippets data. `; } } } else { contentSchemaDesc += ' Data Checked: Foundation schemas (Organization, Person, WebSite, BreadcrumbList), Rich result eligibility (Article, Event, Course, FAQ, HowTo, VideoObject, Product, LocalBusiness, Review, ImageObject, ItemList), schema coverage percentage, schema type diversity.'; } // Build Local Entity and Service Area descriptions based on whether we have real Business Profile data let localEntityDesc, serviceAreaDesc; if (hasLocalSignals && localSignalsData) { const napScore = localSignalsData.napConsistencyScore !== null ? localSignalsData.napConsistencyScore : 'N/A'; const serviceAreasCount = localSignalsData.serviceAreas?.length || 0; const locationsCount = localSignalsData.locations?.length || 0; const knowledgePanel = localSignalsData.knowledgePanelDetected ? 'detected' : 'not detected'; localEntityDesc = `Consistency and clarity of brand, person and business entity signals.
AI Importance: AI systems rely on clear entity signals to understand who you are and connect your brand across platforms. Strong entity recognition helps AI include you in knowledge panels and entity-based results.
Data Source:Live data from Google Business Profile API.
Data Checked: NAP consistency (${napScore}%), knowledge panel (${knowledgePanel}), locations (${locationsCount}), LocalBusiness schema presence, Google Business Profile data.`; serviceAreaDesc = `How clearly AI understands where lessons/workshops run and which regions you cover.
AI Importance: AI uses geographic signals to match queries with local intent. Clear service area data helps AI surface your content for location-specific searches and local AI Overviews.
Data Source:Live data from Google Business Profile API.
Data Checked: Service areas (${serviceAreasCount}), NAP consistency (${napScore}%), Google Business Profile service areas.`; } else { localEntityDesc = 'Consistency and clarity of brand, person and business entity signals.
AI Importance: AI systems rely on clear entity signals to understand who you are and connect your brand across platforms. Strong entity recognition helps AI include you in knowledge panels and entity-based results.
Data Source: ⚠️ Derived calculation from GSC position/CTR (not using real local signals yet).
Data Checked: Currently calculated from search performance metrics. Real data pending: LocalBusiness schema presence, NAP consistency, Google Business Profile data, knowledge panel detection.'; serviceAreaDesc = 'How clearly AI understands where lessons/workshops run and which regions you cover.
AI Importance: AI uses geographic signals to match queries with local intent. Clear service area data helps AI surface your content for location-specific searches and local AI Overviews.
Data Source: ⚠️ Derived calculation from Local Entity score (not using real service area data yet).
Data Checked: Currently calculated from Local Entity. Real data pending: ServiceArea schema markup, Google Business Profile service areas, geographic keywords, location pages.'; } // Format GSC data for display - use current data parameter const currentGSCData = data || searchDataForBreakdown || {}; const ctr = currentGSCData.ctr || 0; const avgPosition = currentGSCData.averagePosition || 0; const totalClicks = currentGSCData.totalClicks || 0; const totalImpressions = currentGSCData.totalImpressions || 0; // Phase 3: Build Brand & Entity description let brandDesc = ''; if (key === 'brandOverlay') { const brand = scores.brandOverlay; if (brand) { const share = brand.brandQueryShare || 0; const brandCtr = brand.brandCtr || 0; const pos = brand.brandAvgPosition || 0; const reviewScore = brand.reviewScore || 0; const entityScore = brand.entityScore || 0; brandDesc = `E-A-T overlay for brand demand, reviews, and entity strength. Shows how clearly "Alan Ranger / Alan Ranger Photography" is recognised as a distinct brand in search and AI systems.
AI Importance: Strong branded signals make it easier for AI to trust and summarise you correctly. High branded search share, strong review footprint and a clear entity graph (GBP + knowledge panel) all increase the chance of accurate AI overviews.
Calculation: Combined overlay score from:
• Brand search: share of branded queries, CTR, and average position
• Reviews: Google Business Profile + on-site review scores and volumes
• Entity: NAP consistency, locations, and knowledge panel detection
Data Source: Live data from Google Search Console (brand queries), Google Business Profile API (rating, reviews, locations, service areas), and site reviews snapshot.`; // Show metrics when available if (share > 0 || brandCtr > 0 || pos > 0) { const sharePct = share != null ? (share * 100).toFixed(1) : 'N/A'; const brandCtrPct = brandCtr != null ? (brandCtr * 100).toFixed(1) : 'N/A'; const posStr = pos != null ? pos.toFixed(1) : 'N/A'; brandDesc += ` Current Metrics: Brand queries: ${sharePct}% of impressions, brand CTR ${brandCtrPct}%, avg brand position ${posStr}.`; } } else { brandDesc = 'No brand overlay data available – run an audit with access to query data.'; } } const descriptions = { localEntity: localEntityDesc, serviceArea: serviceAreaDesc, authority: `E-A-T (Experience, Expertise, Authoritativeness, Trustworthiness): Perceived expertise and trust in the topic space. Authority measures how much the outside world and searchers trust and choose you, not just content quality.
AI Importance: E-A-T is critical for AI systems - they prioritize authoritative, trustworthy sources. Strong E-A-T signals help AI confidently feature your content in AI Overviews and answer boxes.
Calculation: Behaviour Score (40%): CTR for ranking queries (position ≤20) + top-10 CTR. Ranking Score (20%): Average position + top-10 impression share. Backlink Score (20%): Referring domains + quality ratio. Review Score (20%): Combined ratings and counts from GBP + Trustpilot snapshot.
Data Source:Live data from Google Search Console API, Google Business Profile API, historic Trustpilot reviews snapshot, and backlink CSV upload.
Data Checked: CTR ${ctr.toFixed(1)}%, average position ${avgPosition.toFixed(1)}, clicks ${totalClicks.toLocaleString()}, impressions ${totalImpressions.toLocaleString()}, ranking query performance (position ≤20), backlink metrics (referring domains, follow ratio), review ratings and counts.`, visibility: `Frequency and prominence in organic search, local pack, snippets and AI Overviews.
AI Importance: AI systems learn from existing search performance. Higher visibility signals quality and relevance, making AI more likely to surface your content in AI-powered results and featured snippets.
Data Source:Live data from Google Search Console API.
Data Checked: Average position ${avgPosition.toFixed(1)}, CTR ${ctr.toFixed(1)}%, total clicks ${totalClicks.toLocaleString()}, total impressions ${totalImpressions.toLocaleString()}, SERP feature appearances.`, contentSchema: contentSchemaDesc, brandOverlay: brandDesc }; // Generate dynamic next steps based on actual data and scores // Use current data from function parameters const getNextSteps = (pillarKey, pillarScore, gscData, schemaData) => { // Use the schemaData parameter passed to this function const currentSchemaData = schemaData; const steps = []; switch(pillarKey) { case 'contentSchema': if (schemaData && schemaData.status === 'ok' && schemaData.data) { const schemaAuditData = schemaData.data; const { coverage, missingSchemaCount, totalPages, pagesWithSchema, schemaTypes, richEligible, missingTypes } = schemaAuditData; // Collect all types for analysis - use allDetectedTypes if available (all types), otherwise use schemaTypes (all types, sorted by count) const allTypes = new Set(); if (schemaAuditData.allDetectedTypes && Array.isArray(schemaAuditData.allDetectedTypes)) { // Use all detected types for accurate calculation schemaAuditData.allDetectedTypes.forEach(type => { if (type) allTypes.add(type); }); } else if (schemaTypes && Array.isArray(schemaTypes)) { // Fallback: collect from schemaTypes array (contains all types, sorted by count) schemaTypes.forEach(item => { if (item.type) allTypes.add(item.type); }); } // 1. Foundation Schemas (30% weight) const foundationTypes = ['Organization', 'Person', 'WebSite', 'BreadcrumbList']; const foundationPresent = foundationTypes.filter(type => allTypes.has(type)).length; const foundationMissing = foundationTypes.filter(type => !allTypes.has(type)); if (foundationPresent < 4) { steps.push(`Foundation schemas (30%): ${foundationPresent}/4 present. Add: ${foundationMissing.join(', ')}`); } else { steps.push(`Foundation schemas (30%): ✅ All 4 present (Organization, Person, WebSite, BreadcrumbList)`); } // 2. Rich Result Eligibility (35% weight) const richResultTypes = ['Article', 'Event', 'FAQPage', 'Product', 'LocalBusiness', 'Course', 'Review', 'HowTo', 'VideoObject', 'ImageObject', 'ItemList']; const richEligibleCount = Object.values(richEligible || {}).filter(eligible => eligible === true).length; const richMissing = richResultTypes.filter(type => !richEligible[type]); // Check for failed crawls that might affect rich result detection const failedPages = schemaAuditData.missingSchemaPages ? schemaAuditData.missingSchemaPages.filter(p => p.error).length : 0; const hasFailedCrawls = failedPages > 0; if (richEligibleCount < richResultTypes.length) { let richResultMsg = `Rich results (35%): ${richEligibleCount}/${richResultTypes.length} eligible.`; if (richMissing.length > 0) { richResultMsg += ` Missing: ${richMissing.slice(0, 3).join(', ')}${richMissing.length > 3 ? '...' : ''}`; if (hasFailedCrawls) { richResultMsg += ` (Note: ${failedPages} page${failedPages !== 1 ? 's' : ''} failed to crawl - missing types may exist but weren't detected)`; } } steps.push(richResultMsg); } else { steps.push(`Rich results (35%): ✅ All ${richResultTypes.length} types eligible`); } // 3. Coverage (20% weight) if (coverage < 100) { steps.push(`Coverage (20%): ${coverage}% - Add schema to ${missingSchemaCount || 0} pages without markup`); } else { steps.push(`Coverage (20%): ✅ 100% - All pages have schema`); } // 4. Type Diversity (15% weight) const uniqueTypesCount = allTypes.size; if (uniqueTypesCount < 15) { steps.push(`Diversity (15%): ${uniqueTypesCount} unique types. Add more schema types to reach 15+ for maximum score`); } else { steps.push(`Diversity (15%): ✅ ${uniqueTypesCount} unique types (excellent diversity)`); } } else { steps.push(`Status: Schema audit data not available - run audit to see detailed metrics`); } break; case 'visibility': // Use current data from function parameters const currentGSCForVisibility = data || searchDataForBreakdown || {}; if (currentGSCForVisibility) { const position = currentGSCForVisibility.averagePosition || 0; const ctr = currentGSCForVisibility.ctr || 0; if (position > 10) { steps.push(`Average position: ${position.toFixed(1)} - Target top 10 positions (currently ranking ${position > 20 ? 'below' : 'in'} page ${Math.ceil(position / 10)})`); } else { steps.push(`Average position: ${position.toFixed(1)} - Excellent! Maintain top 10 rankings`); } if (ctr < 2.0) { steps.push(`CTR: ${ctr.toFixed(1)}% - Improve click-through rate (target: 2%+) with better titles/meta descriptions`); } else { steps.push(`CTR: ${ctr.toFixed(1)}% - Good CTR! Continue optimizing for featured snippets`); } if (currentGSCForVisibility.totalImpressions < 1000) { steps.push(`Impressions: ${currentGSCForVisibility.totalImpressions} - Increase visibility by targeting more keywords`); } } break; case 'authority': // Use current data from function parameters, not stale localStorage const currentGSCForAuthority = data || searchDataForBreakdown || {}; if (currentGSCForAuthority) { const ctr = currentGSCForAuthority.ctr || 0; const position = currentGSCForAuthority.averagePosition || 0; // Use current saved data (already loaded at function start) const authorityComponents = scores?.authorityComponents; const backlinkMetrics = saved?.backlinkMetrics; const localSignals = saved?.localSignals; // Always use correct Trustpilot snapshot (4.6, 610) - override any old cached values const siteReviews = getTrustpilotSnapshot(saved?.siteReviews); // Show component-specific suggestions if (authorityComponents) { if (authorityComponents.behaviour < 50) { steps.push(`Behaviour Score (${Math.round(authorityComponents.behaviour)}): Improve CTR for ranking queries. Target 5%+ CTR for all queries, 10%+ for top-10 positions`); } if (authorityComponents.ranking < 50) { steps.push(`Ranking Score (${Math.round(authorityComponents.ranking)}): Improve average position and increase top-10 impression share`); } if (authorityComponents.backlinks < 50) { if (backlinkMetrics && backlinkMetrics.referringDomains > 0) { steps.push(`Backlink Score (${Math.round(authorityComponents.backlinks)}): Increase referring domains (current: ${backlinkMetrics.referringDomains}, target: 100+) and improve follow ratio (current: ${Math.round((backlinkMetrics.followRatio || 0) * 100)}%)`); } else { steps.push(`Backlink Score (${Math.round(authorityComponents.backlinks)}): Upload backlink CSV to measure domain authority`); } } if (authorityComponents.reviews < 50) { const gbpRating = (localSignals && localSignals.status === 'ok' && localSignals.data) ? localSignals.data.gbpRating : null; const gbpCount = (localSignals && localSignals.status === 'ok' && localSignals.data) ? localSignals.data.gbpReviewCount : null; const siteRating = siteReviews?.siteRating || null; const siteCount = siteReviews?.siteReviewCount || null; if (!gbpRating && !siteRating) { steps.push(`Review Score (${Math.round(authorityComponents.reviews)}): Add GBP and Trustpilot reviews to build trust signals`); } else { steps.push(`Review Score (${Math.round(authorityComponents.reviews)}): Increase review count and maintain high ratings (target: 4.5+ rating, 100+ reviews)`); } } } else { // Fallback to general suggestions if components not available if (ctr < 1.5) { steps.push(`CTR: ${ctr.toFixed(1)}% - Low click-through indicates trust issues. Improve E-A-T signals`); } if (position > 15) { steps.push(`Position: ${position.toFixed(1)} - Improve rankings through comprehensive, expert content`); } } } break; case 'localEntity': if (hasLocalSignals && localSignalsData) { const napScore = localSignalsData.napConsistencyScore !== null ? localSignalsData.napConsistencyScore : 'N/A'; const locationsCount = localSignalsData.locations?.length || 0; const knowledgePanel = localSignalsData.knowledgePanelDetected ? 'detected' : 'not detected'; steps.push(`Current score: ${Math.round(pillarScore)} - Using real Business Profile data`); steps.push(`Data: NAP consistency: ${napScore}%, Knowledge panel: ${knowledgePanel}, Locations: ${locationsCount}`); if (pillarScore < 70) { if (napScore < 100) { steps.push(`Action: Improve NAP consistency (currently ${napScore}%) - ensure Name, Address, and Phone are consistent across all platforms`); } if (!localSignalsData.knowledgePanelDetected) { steps.push(`Action: Work on knowledge panel detection - improve entity signals and citations`); } if (locationsCount === 0) { steps.push(`Action: Add business location to Google Business Profile`); } } else { steps.push(`Status: ✅ Strong local entity signals detected`); } } else { steps.push(`Current score: ${Math.round(pillarScore)} - Using derived calculation from search performance`); steps.push(`Priority: Integrate Google Business Profile API to use real local signals data`); if (pillarScore < 70) { steps.push(`Action: Add LocalBusiness schema markup and ensure NAP consistency`); } } break; case 'serviceArea': if (hasLocalSignals && localSignalsData) { const serviceAreasCount = localSignalsData.serviceAreas?.length || 0; const napScore = localSignalsData.napConsistencyScore !== null ? localSignalsData.napConsistencyScore : 'N/A'; steps.push(`Current score: ${Math.round(pillarScore)} - Using real Business Profile data`); steps.push(`Data: Service areas: ${serviceAreasCount}, NAP consistency: ${napScore}%`); if (pillarScore < 70) { if (serviceAreasCount < 5) { steps.push(`Action: Add more service areas to Google Business Profile (currently ${serviceAreasCount}, target: 5+)`); } if (napScore < 100) { steps.push(`Action: Improve NAP consistency to boost service area score (currently ${napScore}%)`); } } else { steps.push(`Status: ✅ Good service area coverage`); } } else { steps.push(`Current score: ${Math.round(pillarScore)} - Using derived calculation from Local Entity`); steps.push(`Priority: Integrate Google Business Profile API to get real service area data`); if (pillarScore < 70) { steps.push(`Action: Add ServiceArea schema and create location-specific pages`); } } break; } // If no specific steps generated, add generic ones if (steps.length === 0) { if (pillarScore >= 70) { steps.push('Maintain current performance'); steps.push('Monitor for any score drops'); } else if (pillarScore >= 40) { steps.push('Focus on improving this pillar'); steps.push('Review specific metrics above'); } else { steps.push('Critical: Immediate action required'); steps.push('Review all data sources and implement fixes'); } } return steps.slice(0, 3).map(s => `• ${s}`).join('
') || 'No next steps available'; }; // Generate suggestions based on pillar and score (fallback) const getSuggestions = (pillarKey, pillarScore) => { const suggestions = { localEntity: { high: ['Maintain consistent NAP (Name, Address, Phone) across all platforms', 'Continue building personal brand mentions and citations', 'Monitor entity recognition in knowledge panels'], medium: ['Add LocalBusiness schema markup to all location pages', 'Ensure consistent business name and person name across website', 'Build more local citations and directory listings', 'Create an About page with clear entity information', 'Add author markup to content'], low: ['Implement LocalBusiness schema markup immediately', 'Create consistent NAP (Name, Address, Phone) across all platforms', 'Build local citations in relevant directories', 'Add clear About page with entity information', 'Ensure consistent branding across all touchpoints'] }, serviceArea: { high: ['Continue maintaining clear service area information', 'Update location pages as service areas expand'], medium: ['Add ServiceArea schema markup to location pages', 'Create dedicated pages for each service area/region', 'Include clear geographic information in content', 'Add location-specific keywords naturally', 'Update Google Business Profile with service areas'], low: ['Implement ServiceArea schema markup immediately', 'Create location-specific landing pages', 'Add clear geographic service information to homepage', 'Update all location pages with service area details', 'Include city/region names in page titles and content'] }, authority: { high: ['Continue producing in-depth, expert content', 'Maintain citation and backlink building efforts', 'Seek opportunities for expert quotes and mentions', 'Keep author bios and credentials up to date'], medium: ['Create more comprehensive, long-form content (2000+ words)', 'Build backlinks from authoritative photography/education sites', 'Seek guest posting opportunities on relevant blogs', 'Collect and display customer reviews/testimonials', 'Create case studies and detailed tutorials', 'Get cited in industry publications', 'Add author bylines with credentials and experience', 'Display certifications, qualifications, and awards', 'Showcase real-world experience and portfolio work', 'Build trust signals (secure site, clear contact info, privacy policy)'], low: ['Produce comprehensive, expert-level content immediately', 'Build backlinks from authoritative sources', 'Create detailed case studies and tutorials', 'Seek media mentions and expert quotes', 'Display customer reviews prominently', 'Build relationships with industry publications', 'Add clear author credentials and experience to all content', 'Display qualifications, certifications, and professional memberships', 'Create About page highlighting expertise and experience', 'Build trust signals (HTTPS, clear contact, privacy policy, terms)'] }, visibility: { high: ['Continue optimizing for featured snippets', 'Maintain strong ranking positions', 'Monitor SERP feature opportunities'], medium: ['Optimize content for featured snippets (answer boxes)', 'Target long-tail keywords with lower competition', 'Improve page load speed and Core Web Vitals', 'Create FAQ schema for common questions', 'Optimize for "People Also Ask" sections', 'Build internal linking structure'], low: ['Optimize for featured snippets immediately', 'Target low-competition long-tail keywords', 'Improve page speed and mobile experience', 'Add FAQ schema markup', 'Create content targeting "People Also Ask" queries', 'Build strong internal linking structure'] }, contentSchema: { high: ['Continue maintaining all foundation schemas (Organization, Person, WebSite, BreadcrumbList)', 'Expand rich result eligible types (Article, Event, Course, FAQ, HowTo)', 'Monitor schema validation errors', 'Add more schema type diversity'], medium: ['Add missing foundation schemas (Organization, Person, WebSite, BreadcrumbList)', 'Add rich result eligible types (Article, Event, Course, FAQ, HowTo, VideoObject, Product, LocalBusiness, Review, ImageObject, ItemList)', 'Ensure 100% schema coverage across all pages', 'Increase schema type diversity (target 15+ unique types)', 'Validate all schema using Google Rich Results Test'], low: ['Implement foundation schemas immediately (Organization, Person, WebSite, BreadcrumbList)', 'Add rich result eligible schemas (Article, Event, Course, FAQ, HowTo)', 'Ensure schema coverage reaches 100%', 'Add schema type diversity (target 15+ unique types)', 'Validate all schema using Google Rich Results Test'] } }; const pillarSuggestions = suggestions[pillarKey]; if (!pillarSuggestions) return 'No suggestions available'; let selectedSuggestions = []; if (pillarScore >= 70) { selectedSuggestions = pillarSuggestions.high || []; } else if (pillarScore >= 40) { selectedSuggestions = pillarSuggestions.medium || []; } else { selectedSuggestions = pillarSuggestions.low || []; } // Return top 3-4 suggestions as bullet points return selectedSuggestions.slice(0, 4).map(s => `• ${s}`).join('
') || 'No suggestions available'; }; // Phase 3: Handle Brand & Entity overlay row if (key === 'brandOverlay') { const brand = scores.brandOverlay; const brandScore = brand?.score ?? 0; let brandStatus = 'red'; let brandLabel = 'Red'; if (brandScore >= 70) { brandStatus = 'green'; brandLabel = 'Green'; } else if (brandScore >= 40) { brandStatus = 'amber'; brandLabel = 'Amber'; } // Get brand priority for improvement suggestions const brandPriority = getBrandPriority({ brandOverlay: brand }); let brandSuggestion = ''; if (brandPriority) { brandSuggestion = brandPriority.message; } else if (brandScore >= 70) { brandSuggestion = 'Brand & entity signals are strong. Maintain a steady flow of new reviews and consistent use of your full brand name across site and off-site mentions.'; } else if (!brand) { brandSuggestion = 'No brand overlay data available – run an audit with access to query data.'; } else { brandSuggestion = 'No immediate brand actions – focus on Authority behaviour first.'; } const brandDataDate = getPillarDataDate('authority'); // Use same date as Authority (GSC-based) const brandDataDateDisplay = brandDataDate ? `${brandDataDate}` : 'N/A'; return ` `; } const weight = pillarWeights[key] || 0; const isEven = index % 2 === 0; // Add CSV download button for Content/Schema let descriptionCell = descriptions[key] || ''; if (key === 'contentSchema' && schemaAudit && schemaAudit.status === 'ok' && schemaAudit.data) { const schemaData = schemaAudit.data; const missingSchemaCount = schemaData.missingSchemaCount || 0; const missingSchemaPages = schemaData.missingSchemaPages || []; // Always show button, but disable if no missing pages const buttonDisabled = missingSchemaCount === 0; const buttonStyle = buttonDisabled ? 'padding: 0.4rem 0.8rem; font-size: 0.8rem; background: #9ca3af; color: white; border: none; border-radius: 4px; cursor: not-allowed; opacity: 0.6;' : 'padding: 0.4rem 0.8rem; font-size: 0.8rem; background: #10b981; color: white; border: none; border-radius: 4px; cursor: pointer;'; const buttonText = missingSchemaCount > 0 ? `Download pages without schema (CSV) - ${missingSchemaCount} pages` : 'Download pages without schema (CSV) - No missing pages'; const downloadTooltip = missingSchemaCount > 0 ? `Download a CSV file containing ${missingSchemaCount} page URLs that are missing schema markup. Use this list to prioritize which pages need schema added.` : 'All pages have schema markup. No download available.'; descriptionCell += `
`; } // Get data date for this pillar const pillarDataDate = getPillarDataDate(key); const dataDateDisplay = pillarDataDate ? `${pillarDataDate}` : 'N/A'; return ` `; }).join('')}
Pillar Score Weight Status Description Improvement Suggestions Data Date
Brand & Entity (overlay)
${brand ? Math.round(brandScore) : 0} ${brand ? ` ${brandLabel} ` : ` N/A `} ${descriptions.brandOverlay || ''} ${brandSuggestion || 'No suggestions available'} ${brandDataDateDisplay}
${pillarNames[key]}
${Math.round(score)} ${(weight * 100).toFixed(0)}% ${rag.label} ${descriptionCell} ${getNextSteps(key, score, currentGSCData, schemaAuditData)} ${dataDateDisplay}

RAG Status Guide: Green (70-100) = Strong performance, Amber (40-69) = Needs improvement, Red (0-39) = Critical issues

Weighting: Pillars are weighted by their importance for AI search systems. Authority (30%) and Content/Schema (25%) are most critical, as AI relies heavily on E-A-T signals and structured data. Visibility (20%) reflects how AI learns from existing performance, while Local Entity (15%) and Service Area (10%) are less critical for AI-powered results. Brand & Entity is treated as an overlay metric – it does not change the GAIO score but influences how AI systems attribute your content and build summaries.

Note: Scores shown are for the last date in the trend chart range. If different audits were run on different days with different page counts or data scopes, scores may vary even if the underlying content didn't change. The "Data Date" column shows when the data was collected for each pillar.

`; // Insert scorecard table after Score Trends chart (at the end) const trendChart = document.getElementById('trendChart'); if (trendChart && trendChart.parentElement && trendChart.parentElement.parentNode) { // Find the chart container (parent of canvas) and insert after it const trendChartContainer = trendChart.parentElement; trendChartContainer.parentNode.insertBefore(scorecardTable, trendChartContainer.nextSibling); } else { // Fallback: insert at end of dashboard container const dashboardContainer = document.getElementById('dashboard'); if (dashboardContainer) { dashboardContainer.appendChild(scorecardTable); } else { // Last resort: insert after pillar cards pillarCards.parentNode.insertBefore(scorecardTable, pillarCards.nextSibling); } } // Add CSV download handler for missing schema pages const downloadBtn = document.getElementById('download-missing-schema'); if (downloadBtn && schemaAudit && schemaAudit.status === 'ok' && schemaAudit.data) { const schemaData = schemaAudit.data; const missingSchemaCount = schemaData.missingSchemaCount || 0; const missingSchemaPages = schemaData.missingSchemaPages || []; debugLog(`Missing schema pages: count=${missingSchemaCount}, pages=${missingSchemaPages.length}`, 'info'); debugLog(`Missing schema data: ${JSON.stringify(missingSchemaPages)}`, 'info'); if (missingSchemaCount > 0 && missingSchemaPages.length > 0) { downloadBtn.addEventListener('click', () => { // Check if any pages have error field to determine CSV columns const hasErrors = missingSchemaPages.some(p => p.error); const headers = hasErrors ? ['url', 'parentUrl', 'error'] : ['url', 'parentUrl']; const rows = [ headers, ...missingSchemaPages.map(p => [ p.url, p.parentUrl || '', ...(hasErrors ? [p.error || ''] : []) ]), ]; const csv = rows .map(r => r.map(v => `"${(v || '').replace(/"/g, '""')}"`).join(',')) .join('\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'ai-geo-missing-schema-pages.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }); } else { // Button is already disabled in the HTML, just log debugLog(`No missing schema pages to download (count: ${missingSchemaCount})`, 'info'); } } else if (!downloadBtn) { debugLog('Download button not found in DOM', 'warn'); } // Display snippet readiness with pie chart visualization const snippetScoreElement = document.getElementById('snippetReadinessScore'); const gaugeStatus = document.getElementById('gaugeStatus'); const legendElement = document.getElementById('snippetReadinessLegend'); if (snippetScoreElement) { snippetScoreElement.textContent = snippetReadiness; // Color code overall score based on value let colorClass = '#ef4444'; // red let statusText = 'Critical'; if (snippetReadiness >= 70) { colorClass = '#10b981'; // green statusText = 'Strong'; } else if (snippetReadiness >= 40) { colorClass = '#f59e0b'; // amber statusText = 'Needs Improvement'; } snippetScoreElement.style.color = colorClass; // Update status text if (gaugeStatus) { gaugeStatus.textContent = statusText; gaugeStatus.style.color = colorClass; } } // Create pie chart showing weighted components const pieChartCanvas = document.getElementById('snippetReadinessPieChart'); if (pieChartCanvas && scores) { // Destroy existing chart if it exists if (window.snippetReadinessChart) { window.snippetReadinessChart.destroy(); } const contentSchemaScore = Math.round(scores.contentSchema || 0); const visibilityScore = Math.round(scores.visibility || 0); const authorityScore = Math.round(typeof scores.authority === 'object' ? (scores.authority.score || 0) : (scores.authority || 0)); // Generate top 5 actionable levers to improve snippet readiness const explanationDiv = document.getElementById('snippetReadinessExplanation'); const topActionsList = document.getElementById('topActionsList'); if (explanationDiv && topActionsList && scores) { const contentSchemaScore = Math.round(scores.contentSchema || 0); const visibilityScore = Math.round(scores.visibility || 0); const authorityScore = Math.round(typeof scores.authority === 'object' ? (scores.authority.score || 0) : (scores.authority || 0)); const actions = []; // Authority (25% weight) - usually lowest, highest impact potential if (authorityScore < 70) { const potentialGain = (70 - authorityScore) * 0.25; // Max potential points if improved to 70 if (data && data.ctr !== undefined) { const ctr = data.ctr || 0; if (ctr < 1.5) { actions.push({ priority: 1, impact: potentialGain, text: `Improve Authority (currently ${authorityScore}%): Increase CTR from ${ctr.toFixed(1)}% to 2%+ by optimizing titles and meta descriptions. Potential gain: +${potentialGain.toFixed(1)} points.` }); } else { actions.push({ priority: 1, impact: potentialGain, text: `Improve Authority (currently ${authorityScore}%): Build backlinks and improve E-A-T signals. Potential gain: +${potentialGain.toFixed(1)} points.` }); } } else { actions.push({ priority: 1, impact: potentialGain, text: `Improve Authority (currently ${authorityScore}%): Build backlinks, improve E-A-T signals, and optimize CTR. Potential gain: +${potentialGain.toFixed(1)} points.` }); } } // Content/Schema (40% weight) - highest weight if (contentSchemaScore < 100 && schemaAudit && schemaAudit.status === 'ok' && schemaAudit.data) { const schemaData = schemaAudit.data; const allTypes = new Set(); // Use allDetectedTypes if available (all types), otherwise use schemaTypes (all types, sorted by count) if (schemaData.allDetectedTypes && Array.isArray(schemaData.allDetectedTypes)) { schemaData.allDetectedTypes.forEach(type => { if (type) allTypes.add(type); }); } else if (schemaData.schemaTypes && Array.isArray(schemaData.schemaTypes)) { schemaData.schemaTypes.forEach(item => { if (item.type) allTypes.add(item.type); }); } const foundationTypes = ['Organization', 'Person', 'WebSite', 'BreadcrumbList']; const foundationPresent = foundationTypes.filter(type => allTypes.has(type)).length; const richEligibleCount = Object.values(schemaData.richEligible || {}).filter(eligible => eligible === true).length; const uniqueTypesCount = allTypes.size; // Calculate potential improvements if (foundationPresent < 4) { const missingFoundation = foundationTypes.filter(type => !allTypes.has(type)); const potentialGain = ((4 - foundationPresent) / 4) * 30 * 0.4; // 30% weight of 40% total actions.push({ priority: 2, impact: potentialGain, text: `Add missing foundation schemas: ${missingFoundation.join(', ')}. Currently ${foundationPresent}/4. Potential gain: +${potentialGain.toFixed(1)} points.` }); } // Get list of all rich result types (must match api/schema-audit.js) const richResultTypes = ['Article', 'Event', 'FAQPage', 'Product', 'LocalBusiness', 'Course', 'Review', 'HowTo', 'VideoObject', 'ImageObject', 'ItemList']; const totalRichResultTypes = richResultTypes.length; if (richEligibleCount < totalRichResultTypes) { const potentialGain = ((totalRichResultTypes - richEligibleCount) / totalRichResultTypes) * 35 * 0.4; // 35% weight of 40% total // Get list of which rich result types are missing const applicableMissingTypes = richResultTypes.filter(type => { return !schemaData.richEligible || !schemaData.richEligible[type]; }); // Check for failed crawls that might affect rich result detection const failedPages = schemaData.missingSchemaPages ? schemaData.missingSchemaPages.filter(p => p.error).length : 0; const hasFailedCrawls = failedPages > 0; // Build suggestion text let suggestionText; if (applicableMissingTypes.length > 0) { suggestionText = `Add more rich result types. Currently ${richEligibleCount}/${totalRichResultTypes} eligible. Add: ${applicableMissingTypes.join(', ')} schemas.`; if (hasFailedCrawls) { suggestionText += ` Note: ${failedPages} page${failedPages !== 1 ? 's' : ''} failed to crawl - missing types may exist but weren't detected.`; } suggestionText += ` Potential gain: +${potentialGain.toFixed(1)} points.`; } else { suggestionText = `Add more rich result types. Currently ${richEligibleCount}/${totalRichResultTypes} eligible. Potential gain: +${potentialGain.toFixed(1)} points.`; } actions.push({ priority: 3, impact: potentialGain, text: suggestionText }); } if (uniqueTypesCount < 15) { const potentialGain = ((15 - uniqueTypesCount) / 15) * 15 * 0.4; // 15% weight of 40% total actions.push({ priority: 4, impact: potentialGain, text: `Increase schema diversity. Currently ${uniqueTypesCount} types (target: 15+). Add more schema types across different page types. Potential gain: +${potentialGain.toFixed(1)} points.` }); } } // Visibility (35% weight) if (visibilityScore < 90 && data && data.averagePosition !== undefined) { const position = data.averagePosition || 0; const ctr = data.ctr || 0; const potentialGain = (90 - visibilityScore) * 0.35; if (position > 10) { actions.push({ priority: 5, impact: potentialGain, text: `Improve Visibility (currently ${visibilityScore}%): Target top 10 positions. Current average position: ${position.toFixed(1)}. Optimize for featured snippets. Potential gain: +${potentialGain.toFixed(1)} points.` }); } else if (ctr < 2.0) { actions.push({ priority: 5, impact: potentialGain, text: `Improve Visibility (currently ${visibilityScore}%): Increase CTR from ${ctr.toFixed(1)}% to 2%+ with better titles and meta descriptions. Potential gain: +${potentialGain.toFixed(1)} points.` }); } } // Sort by impact (highest first) and take top 5 actions.sort((a, b) => b.impact - a.impact); const top5Actions = actions.slice(0, 5); // Update the list topActionsList.innerHTML = top5Actions.map((action, index) => `
  • ${action.text}
  • ` ).join(''); if (top5Actions.length === 0) { topActionsList.innerHTML = '
  • All components are performing well! Maintain current performance.
  • '; } explanationDiv.style.display = 'block'; } else if (explanationDiv) { explanationDiv.style.display = 'none'; } // Calculate weighted contribution of each component const contentSchemaContribution = (contentSchemaScore * 0.4).toFixed(1); const visibilityContribution = (visibilityScore * 0.35).toFixed(1); const authorityContribution = (authorityScore * 0.25).toFixed(1); // Create nested pie chart with fill percentages // Outer ring: Weighting (40%, 35%, 25%) // Inner fill: Score percentage within each segment (like fuel gauge) const weights = [40, 35, 25]; const scoresArray = [contentSchemaScore, visibilityScore, authorityScore]; // Match trend chart colors (avoid red/amber/green to prevent RAG confusion) const colors = ['#6b7280', '#2563eb', '#99004C']; // Grey (Content/Schema), Blue (Visibility), Dark pink/magenta (Authority) // Calculate outer ring data (weighting percentages) const outerData = weights; // Inner ring: Same segment sizes as outer, but we'll use custom drawing to fill only the score percentage // The inner data must match outer segment sizes so they align const innerData = weights; // Same sizes as outer // Create chart with custom drawing for inner fill segments window.snippetReadinessChart = new Chart(pieChartCanvas, { type: 'doughnut', data: { labels: [ `Content/Schema`, `Visibility`, `Authority` ], datasets: [ { // Outer ring: Weighting percentages (40%, 35%, 25%) label: 'Weight', data: outerData, backgroundColor: colors, borderWidth: 4, borderColor: '#ffffff', cutout: '60%' // Leave room for inner fill }, { // Inner ring: Same segment sizes, but will be custom-drawn to show score fill label: 'Score Fill', data: innerData, backgroundColor: colors.map((color, i) => { // Use darker version of segment color for unfilled portion return color + '40'; // Add transparency }), borderWidth: 4, borderColor: '#ffffff', cutout: '75%' // Inner ring showing fill } ] }, options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false // We'll use custom legend }, tooltip: { callbacks: { label: function(context) { const datasetIndex = context.datasetIndex; const index = context.dataIndex; if (datasetIndex === 0) { // Outer ring: Show weighting return `Weight: ${weights[index]}%`; } else { // Inner fill: Show score and fill percentage const score = scoresArray[index]; const fillPercent = (score / 100) * 100; return `Score: ${score}% (${fillPercent.toFixed(0)}% of segment filled)`; } } } } } }, plugins: [{ id: 'innerFillAndLabels', afterDraw: (chart) => { const ctx = chart.ctx; const outerMeta = chart.getDatasetMeta(0); // Outer ring const innerMeta = chart.getDatasetMeta(1); // Inner ring const centerX = chart.chartArea.left + (chart.chartArea.right - chart.chartArea.left) / 2; const centerY = chart.chartArea.top + (chart.chartArea.bottom - chart.chartArea.top) / 2; const outerRadius = (chart.chartArea.right - chart.chartArea.left) / 2; const innerRadius = outerRadius * 0.75; // 75% cutout const outerInnerRadius = outerRadius * 0.60; // 60% cutout (where inner ring starts) // First, clear the inner ring segments (they're drawn by Chart.js but we'll redraw them) // Then draw custom filled portions based on scores outerMeta.data.forEach((outerSegment, index) => { const score = scoresArray[index]; const scorePercent = score / 100; // 0 to 1 const startAngle = outerSegment.startAngle; const endAngle = outerSegment.endAngle; const segmentAngle = endAngle - startAngle; const filledAngle = segmentAngle * scorePercent; const filledEndAngle = startAngle + filledAngle; // Get RAG color for fill let fillColor; if (score >= 70) fillColor = '#10b981'; // Green else if (score >= 40) fillColor = '#f59e0b'; // Amber else fillColor = '#ef4444'; // Red // Draw the filled portion of this inner segment (fuel gauge effect) ctx.save(); ctx.beginPath(); ctx.arc(centerX, centerY, outerInnerRadius, startAngle, filledEndAngle, false); ctx.arc(centerX, centerY, innerRadius, filledEndAngle, startAngle, true); ctx.closePath(); ctx.fillStyle = fillColor; ctx.fill(); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 4; ctx.stroke(); ctx.restore(); // Draw weight percentage in outer segment (larger, white for grey and blue segments, black for yellow) const outerSegmentMidAngle = (startAngle + endAngle) / 2; // Position closer to outer edge but still inside segment (about 75% of outer ring width) const outerSegmentTextRadius = outerRadius - ((outerRadius - outerInnerRadius) * 0.25); const weightTextX = centerX + Math.cos(outerSegmentMidAngle) * outerSegmentTextRadius; const weightTextY = centerY + Math.sin(outerSegmentMidAngle) * outerSegmentTextRadius; ctx.save(); // Use white text for grey (Content/Schema), blue (Visibility), and dark pink (Authority) segments const textColor = (colors[index] === '#6b7280' || colors[index] === '#2563eb' || colors[index] === '#99004C') ? '#ffffff' : '#000000'; ctx.fillStyle = textColor; ctx.font = 'bold 18px system-ui'; // Increased from 14px to 18px ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // Add shadow for readability (dark shadow for white text, light shadow for black text) if (textColor === '#ffffff') { ctx.shadowColor = 'rgba(0, 0, 0, 0.7)'; } else { ctx.shadowColor = 'rgba(255, 255, 255, 0.9)'; } ctx.shadowBlur = 4; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; ctx.fillText(`${weights[index]}%`, weightTextX, weightTextY); ctx.restore(); // Draw label in the center area, aligned to segment const midAngle = (startAngle + endAngle) / 2; // Position label in center area (about 30% from center, well inside the 75% cutout) const labelRadius = outerRadius * 0.25; // Position in center area const labelX = centerX + Math.cos(midAngle) * labelRadius; const labelY = centerY + Math.sin(midAngle) * labelRadius; // Get component names const componentNames = ['Content/Schema', 'Visibility', 'Authority']; const weight = weights[index]; // Calculate text bounds to avoid arrow overlap // Estimate text height: 3 lines with spacing ≈ 45px total height const textHeight = 45; const textWidth = 80; // Approximate max text width // Draw dotted arrow from label to segment edge, routing around text ctx.save(); ctx.strokeStyle = colors[index]; ctx.lineWidth = 2; ctx.setLineDash([5, 5]); // Dotted line ctx.beginPath(); // Start arrow from edge of text area with more padding to avoid overlap // Calculate perpendicular offset to route around text const perpAngle = midAngle + Math.PI / 2; // Perpendicular to segment angle const textOffset = 35; // Increased from 25 to 35 - more padding from text edge const perpOffset = (textHeight / 2) + 8; // Increased padding perpendicular to text const arrowStartX = labelX + Math.cos(midAngle) * textOffset + Math.cos(perpAngle) * perpOffset; const arrowStartY = labelY + Math.sin(midAngle) * textOffset + Math.sin(perpAngle) * perpOffset; // End at inner ring edge const segmentEdgeX = centerX + Math.cos(midAngle) * innerRadius; const segmentEdgeY = centerY + Math.sin(midAngle) * innerRadius; // Draw curved path around text (simple two-segment path) const midX = (arrowStartX + segmentEdgeX) / 2; const midY = (arrowStartY + segmentEdgeY) / 2; // Offset midpoint further outward to curve around text with more clearance const curveOffset = 25; // Increased from 15 to 25 for more clearance const curveMidX = midX + Math.cos(perpAngle) * curveOffset; const curveMidY = midY + Math.sin(perpAngle) * curveOffset; ctx.moveTo(arrowStartX, arrowStartY); ctx.quadraticCurveTo(curveMidX, curveMidY, segmentEdgeX, segmentEdgeY); ctx.stroke(); // Draw arrowhead const arrowLength = 8; const arrowAngle = Math.atan2(segmentEdgeY - curveMidY, segmentEdgeX - curveMidX); ctx.setLineDash([]); // Solid for arrowhead ctx.beginPath(); ctx.moveTo(segmentEdgeX, segmentEdgeY); ctx.lineTo( segmentEdgeX - arrowLength * Math.cos(arrowAngle - Math.PI / 6), segmentEdgeY - arrowLength * Math.sin(arrowAngle - Math.PI / 6) ); ctx.moveTo(segmentEdgeX, segmentEdgeY); ctx.lineTo( segmentEdgeX - arrowLength * Math.cos(arrowAngle + Math.PI / 6), segmentEdgeY - arrowLength * Math.sin(arrowAngle + Math.PI / 6) ); ctx.stroke(); ctx.restore(); // Draw text labels without circles (to prevent overlap) ctx.save(); // Add text shadow for better readability without background ctx.shadowColor = 'rgba(255, 255, 255, 0.8)'; ctx.shadowBlur = 8; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; // Draw text with readable fonts ctx.fillStyle = colors[index]; ctx.font = 'bold 13px system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // First line: Component name ctx.fillText(`${componentNames[index]}`, labelX, labelY - 8); // Second line: Score percentage (colored) - weight removed, now in outer segment ctx.fillStyle = fillColor; ctx.font = 'bold 15px system-ui'; ctx.fillText(`Score: ${score}%`, labelX, labelY + 8); ctx.restore(); }); } }] }); // Create custom legend with scores if (legendElement) { const getRAGColor = (score) => { if (score >= 70) return '#10b981'; if (score >= 40) return '#f59e0b'; return '#ef4444'; }; const getRAGLabel = (score) => { if (score >= 70) return 'Green'; if (score >= 40) return 'Amber'; return 'Red'; }; legendElement.innerHTML = `
    Content/Schema
    Weight: 40%
    Score: ${contentSchemaScore} (${getRAGLabel(contentSchemaScore)})
    Contribution: ${contentSchemaContribution} pts
    Visibility
    Weight: 35%
    Score: ${visibilityScore} (${getRAGLabel(visibilityScore)})
    Contribution: ${visibilityContribution} pts
    Authority
    Weight: 25%
    Score: ${authorityScore} (${getRAGLabel(authorityScore)})
    Contribution: ${authorityContribution} pts
    `; } } // Format numbers for display const formatNumber = (num) => { if (num >= 1000000) { return (num / 1000000).toFixed(2) + 'M'; } else if (num >= 1000) { return (num / 1000).toFixed(2) + 'K'; } return num.toLocaleString(); }; // Display metrics const metricsGrid = document.getElementById('metricsGrid'); metricsGrid.innerHTML = `
    ${formatNumber(data.totalClicks)}
    Total Clicks
    ${formatNumber(data.totalImpressions)}
    Total Impressions
    ${data.averagePosition.toFixed(1)}
    Avg Position
    ${(data.ctr || 0).toFixed(1)}%
    CTR
    `; // Wait a moment for DOM to update, then create charts (charts need visible canvas) setTimeout(async () => { debugLog('Starting chart creation (setTimeout callback)...', 'info'); // Check if Chart.js is loaded debugLog('Checking Chart.js availability...', 'info'); if (typeof Chart === 'undefined') { debugLog('✗ Chart.js library not loaded', 'error'); console.error('Chart.js library not loaded'); showStatus('Chart.js library failed to load. Please refresh the page.', 'error'); return; } debugLog('✓ Chart.js library available', 'success'); debugLog(`Chart constructor: ${typeof Chart}`, 'info'); // Create radar chart debugLog('Creating radar chart...', 'info'); const radarCanvas = document.getElementById('radarChart'); if (!radarCanvas) { debugLog('✗ Radar chart canvas not found', 'error'); console.error('Radar chart canvas not found'); return; } debugLog('✓ Radar chart canvas found', 'success'); // Safely destroy existing chart if it exists debugLog(`Checking for existing radarChart: ${window.radarChart ? 'exists' : 'null'}`, 'info'); try { if (window.radarChart) { debugLog(`radarChart type: ${typeof window.radarChart}`, 'info'); debugLog(`radarChart instanceof Chart: ${window.radarChart instanceof Chart}`, 'info'); debugLog(`radarChart.destroy type: ${typeof window.radarChart.destroy}`, 'info'); // Check if it's actually a Chart instance if (window.radarChart instanceof Chart && typeof window.radarChart.destroy === 'function') { debugLog('Destroying existing radar chart...', 'info'); window.radarChart.destroy(); debugLog('✓ Existing radar chart destroyed', 'success'); } else { debugLog('Existing radarChart is not a valid Chart instance, clearing...', 'info'); } window.radarChart = null; } else { debugLog('No existing radar chart to destroy', 'info'); } } catch (e) { debugLog(`✗ Error destroying existing radar chart: ${e.message}`, 'error'); debugLog(`Stack: ${e.stack}`, 'error'); console.warn('Error destroying existing radar chart:', e); window.radarChart = null; } const radarCtx = radarCanvas.getContext('2d'); debugLog('Creating new Chart instance for radar chart...', 'info'); try { // Use ordered pillars for consistent ordering const orderedPillars = getOrderedPillars(scores); const orderedLabels = orderedPillars.map(([key]) => pillarNames[key]); const orderedData = orderedPillars.map(([, score]) => score); // Define pillar colors for radar chart (matching trend chart) const radarPillarColors = { 'Local Entity': 'rgba(147, 51, 234, 1)', // Purple 'Service Area': '#00FFFF', // Cyan (not RAG color) 'Authority': '#99004C', // Dark pink/magenta 'Visibility': 'rgba(37, 99, 235, 1)', // Blue 'Content / Schema': 'rgba(107, 114, 128, 1)' // Grey }; window.radarChart = new Chart(radarCtx, { type: 'radar', data: { labels: orderedLabels, datasets: [{ label: 'Current Scores', data: orderedData, backgroundColor: 'rgba(59, 130, 246, 0.2)', borderColor: 'rgba(37, 99, 235, 1)', pointBackgroundColor: 'rgba(37, 99, 235, 1)', pointBorderColor: '#fff', pointHoverBackgroundColor: '#fff', pointHoverBorderColor: 'rgba(37, 99, 235, 1)' }] }, options: { responsive: true, maintainAspectRatio: false, layout: { padding: { top: 20, bottom: 20, left: 20, right: 20 } }, scales: { r: { beginAtZero: false, min: 20, max: 100, ticks: { stepSize: 20, font: { size: 14, weight: 'bold' }, color: '#1e293b' }, pointLabels: { font: { size: 16, weight: 'bold' }, color: '#1e293b', padding: 20 }, grid: { color: 'rgba(100, 116, 139, 0.2)' }, angleLines: { color: 'rgba(100, 116, 139, 0.3)' } } }, plugins: { legend: { labels: { font: { size: 14, weight: 'bold' }, padding: 15 } } } }, plugins: [{ id: 'radarScoreLabels', afterDraw: (chart) => { const ctx = chart.ctx; const scale = chart.scales.r; const pointLabelItems = scale._pointLabelItems || []; const dataset = chart.data.datasets[0]; const meta = chart.getDatasetMeta(0); // Color each point with its pillar color (Chart.js already draws the lines) pointLabelItems.forEach((item, index) => { if (item && orderedData[index] !== undefined) { const score = orderedData[index]; const label = orderedLabels[index]; const color = radarPillarColors[label] || 'rgba(37, 99, 235, 1)'; // Get the point for this index const point = meta.data[index]; if (point) { // Draw point in pillar color (Chart.js already draws the connecting lines) ctx.save(); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(point.x, point.y, 6, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); ctx.restore(); } // Get RAG color based on score for text let ragColor = '#1e293b'; // Default dark if (score >= 70) ragColor = '#10b981'; // Green else if (score >= 40) ragColor = '#f59e0b'; // Amber else ragColor = '#ef4444'; // Red // Draw the score percentage directly below the label ctx.save(); ctx.fillStyle = ragColor; ctx.font = 'bold 14px system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.shadowColor = 'rgba(255, 255, 255, 0.9)'; ctx.shadowBlur = 4; // Position it 20px below the label const scoreY = item.y + 20; ctx.fillText(`${Math.round(score)}%`, item.x, scoreY); ctx.restore(); } }); } }] }); debugLog('✓ Radar chart created successfully', 'success'); debugLog(`radarChart type after creation: ${typeof window.radarChart}`, 'info'); debugLog(`radarChart instanceof Chart: ${window.radarChart instanceof Chart}`, 'info'); } catch (e) { debugLog(`✗ Error creating radar chart: ${e.message}`, 'error'); debugLog(`Stack: ${e.stack}`, 'error'); console.error('Error creating radar chart:', e); } // Create trend chart (mock data for now) debugLog('Creating trend chart...', 'info'); const trendCanvas = document.getElementById('trendChart'); if (!trendCanvas) { debugLog('✗ Trend chart canvas not found', 'error'); console.error('Trend chart canvas not found'); return; } debugLog('✓ Trend chart canvas found', 'success'); // Show loading state while fetching data const trendChartContainer = trendCanvas.parentElement; if (trendChartContainer) { // Remove any existing error messages const existingError = trendChartContainer.querySelector('.trend-chart-error'); if (existingError) existingError.remove(); // Show loading spinner const loadingDiv = document.createElement('div'); loadingDiv.className = 'trend-chart-loading'; loadingDiv.style.cssText = 'text-align: center; padding: 2rem; color: #64748b; font-size: 0.9rem;'; loadingDiv.innerHTML = '
    Loading historical data from Supabase...'; // Add spin animation if not already in stylesheet if (!document.getElementById('trend-chart-spin-style')) { const style = document.createElement('style'); style.id = 'trend-chart-spin-style'; style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }'; document.head.appendChild(style); } trendChartContainer.insertBefore(loadingDiv, trendCanvas); } // Safely destroy existing chart if it exists debugLog(`Checking for existing trendChart: ${window.trendChart ? 'exists' : 'null'}`, 'info'); try { if (window.trendChart) { debugLog(`trendChart type: ${typeof window.trendChart}`, 'info'); debugLog(`trendChart instanceof Chart: ${window.trendChart instanceof Chart}`, 'info'); debugLog(`trendChart.destroy type: ${typeof window.trendChart.destroy}`, 'info'); // Check if it's actually a Chart instance if (window.trendChart instanceof Chart && typeof window.trendChart.destroy === 'function') { debugLog('Destroying existing trend chart...', 'info'); window.trendChart.destroy(); debugLog('✓ Existing trend chart destroyed', 'success'); } else { debugLog('Existing trendChart is not a valid Chart instance, clearing...', 'info'); } window.trendChart = null; } else { debugLog('No existing trend chart to destroy', 'info'); } } catch (e) { debugLog(`✗ Error destroying existing trend chart: ${e.message}`, 'error'); debugLog(`Stack: ${e.stack}`, 'error'); console.warn('Error destroying existing trend chart:', e); window.trendChart = null; } const trendCtx = trendCanvas.getContext('2d'); const dateRange = parseInt(document.getElementById('dateRange').value) || 30; debugLog(`Creating trend chart for date range: ${dateRange} days`, 'info'); // Determine number of data points and label frequency based on date range let numDataPoints, labelStep, dateFormat; if (dateRange <= 30) { // For 30 days or less: show daily data, all labels numDataPoints = dateRange; labelStep = 1; dateFormat = { month: 'short', day: 'numeric' }; } else if (dateRange <= 90) { // For 90 days: show daily data, label every 3-5 days numDataPoints = dateRange; labelStep = Math.ceil(dateRange / 20); // ~20 labels max dateFormat = { month: 'short', day: 'numeric' }; } else if (dateRange <= 180) { // For 6 months: show weekly data, label every week numDataPoints = Math.ceil(dateRange / 7); labelStep = 1; dateFormat = { month: 'short', day: 'numeric' }; } else { // For 12 months: show weekly data, label every 2-4 weeks numDataPoints = Math.ceil(dateRange / 7); labelStep = Math.ceil(numDataPoints / 15); // ~15 labels max dateFormat = { month: 'short', day: 'numeric' }; } // Generate date labels and store Date objects const allDates = Array.from({ length: numDataPoints }, (_, i) => { const d = new Date(); if (dateRange <= 90) { // Daily data d.setDate(d.getDate() - (numDataPoints - 1 - i)); } else { // Weekly data d.setDate(d.getDate() - ((numDataPoints - 1 - i) * 7)); } return d; }); // Store Date objects for period detection (for mock data) const mockDateObjects = allDates.slice(); // Create labels array with appropriate spacing and year detection let lastVisibleYear = null; const dates = allDates.map((d, i) => { if (i % labelStep === 0 || i === allDates.length - 1) { const currentYear = d.getFullYear(); const formattedDate = d.toLocaleDateString('en-GB', dateFormat); // Add year if it changed from the last visible label if (lastVisibleYear !== null && currentYear !== lastVisibleYear) { lastVisibleYear = currentYear; return `${formattedDate} ${currentYear}`; } lastVisibleYear = currentYear; return formattedDate; } return ''; // Empty string for labels we don't want to show }); // Store date objects for mock data (will be updated if timeseries data exists) chartDateObjects = mockDateObjects; debugLog('Creating new Chart instance for trend chart...', 'info'); try { // CRITICAL FIX: Always fetch fresh timeseries from Supabase instead of using stale data.timeseries // This ensures we have the latest GSC data, not just what was saved during the last audit let timeseries = []; const propertyUrl = document.getElementById('propertyUrl')?.value || data?.propertyUrl || ''; if (propertyUrl) { try { const endDate = new Date().toISOString().split('T')[0]; const startDate = new Date(); startDate.setDate(startDate.getDate() - dateRange); // Use chart's date range const startDateStr = startDate.toISOString().split('T')[0]; debugLog(`Fetching fresh timeseries from Supabase for date range: ${startDateStr} to ${endDate}`, 'info'); const timeseriesResponse = await fetch(apiUrl(`/api/supabase/get-audit-history?propertyUrl=${encodeURIComponent(propertyUrl)}&startDate=${startDateStr}&endDate=${endDate}`)); if (timeseriesResponse.ok) { const timeseriesData = await timeseriesResponse.json(); if (timeseriesData.status === 'ok' && timeseriesData.timeseries && Array.isArray(timeseriesData.timeseries)) { timeseries = timeseriesData.timeseries; debugLog(`✓ Fetched ${timeseries.length} fresh timeseries data points from Supabase (last date: ${timeseries.length > 0 ? timeseries[timeseries.length - 1].date : 'none'})`, 'success'); } else { debugLog(`⚠ Supabase timeseries response missing data, falling back to data.timeseries`, 'warn'); timeseries = data.timeseries || []; } } else { debugLog(`⚠ Failed to fetch timeseries from Supabase (${timeseriesResponse.status}), falling back to data.timeseries`, 'warn'); timeseries = data.timeseries || []; } } catch (fetchError) { debugLog(`⚠ Error fetching fresh timeseries: ${fetchError.message}, falling back to data.timeseries`, 'warn'); timeseries = data.timeseries || []; } } else { debugLog(`⚠ No property URL found, using data.timeseries`, 'warn'); timeseries = data.timeseries || []; } debugLog(`Using ${timeseries.length} timeseries data points (last date: ${timeseries.length > 0 ? timeseries[timeseries.length - 1].date : 'none'})`, 'info'); // Check if we have data if (!timeseries || timeseries.length === 0) { debugLog('⚠ No timeseries data available. Chart will show mock data. Run a new audit to get real GSC historical data.', 'warn'); // Show message to user (only if message doesn't already exist) const trendCanvas = document.getElementById('trendChart'); if (trendCanvas && trendCanvas.parentElement) { // Check if message already exists const existingMessage = trendCanvas.parentElement.querySelector('.trend-chart-warning'); if (!existingMessage) { const messageDiv = document.createElement('div'); messageDiv.className = 'trend-chart-warning'; messageDiv.style.cssText = 'background: #fff3cd; padding: 1rem; border-radius: 4px; border-left: 3px solid #f59e0b; margin-bottom: 1rem; font-size: 0.9rem; color: #856404;'; messageDiv.innerHTML = 'No historical data available. The trend chart requires timeseries data from Google Search Console. Please run a new audit to populate the chart with real historical data.'; trendCanvas.parentElement.insertBefore(messageDiv, trendCanvas); } } } // If we have timeseries data, use it; otherwise fall back to mock data let localEntityData, serviceAreaData, authorityData, visibilityData, contentSchemaData, brandOverlayData; let contentSchemaDataEstimated = []; // Declare in outer scope for use in chart creation let chartDates = dates; let chartDateObjects = []; // Store Date objects for period detection // Declare maps in outer scope so they're accessible everywhere // Also make them global so displayDashboard can access them for Data Date display let contentSchemaHistory = []; let contentSchemaMap = new Map(); let localEntityMap = new Map(); let serviceAreaMap = new Map(); let authorityMap = new Map(); // Store historical Authority scores from Supabase (legacy: single value) let authorityBySegmentMap = new Map(); // Store historical segmented Authority scores from Supabase (new: {all, nonEducation, money}) let visibilityMap = new Map(); // Store historical Visibility scores from Supabase let brandOverlayMap = new Map(); // Store historical Brand Overlay scores from Supabase // Make maps global for access by displayDashboard window.visibilityMap = visibilityMap; window.authorityMap = authorityMap; window.brandOverlayMap = brandOverlayMap; const currentContentSchema = scores.contentSchema || 0; const currentBrandOverlay = scores.brandOverlay?.score || null; // Declare latestAuditDateStr and latestAuditDate in outer scope so they're accessible in timeseries.forEach let today = new Date(); today.setHours(0, 0, 0, 0); let todayStr = today.toISOString().split('T')[0]; let latestAuditDateStr = todayStr; // Default to today if no audit found let latestAuditDate = null; // Will be set from historical data if (timeseries.length > 0) { // Fetch historical Content/Schema data from Supabase (async operation) const propertyUrl = document.getElementById('propertyUrl')?.value || ''; // Use the actual date range from timeseries data (not calculated from today) // This ensures we query the correct year (2024 vs 2025) const timeseriesStartDate = timeseries[0].date; // First date in timeseries const timeseriesEndDate = timeseries[timeseries.length - 1].date; // Last date in timeseries // CRITICAL: Query ALL historical audit data, not just timeseries range // The user manually added 18 months of historical data, so we need to fetch all of it // Query from 2 years ago to today to ensure we get all historical data // Don't limit to timeseries range - fetch all available historical audit data const historicalStartDate = new Date(); historicalStartDate.setFullYear(historicalStartDate.getFullYear() - 2); const startDate = historicalStartDate.toISOString().split('T')[0]; // 2 years ago const endDate = todayStr; // Use today, not timeseries end date debugLog(`Fetching Content/Schema history from Supabase: ${startDate} to ${endDate} (ALL historical data)`, 'info'); debugLog(`Timeseries date range: ${timeseriesStartDate} to ${timeseriesEndDate}`, 'info'); // Fetch historical data asynchronously (includes Content/Schema AND Business Profile data) if (propertyUrl) { try { contentSchemaHistory = await fetchContentSchemaHistory(propertyUrl, startDate, endDate); // Handle multiple audits per day: only keep the latest audit for each date // Group by date and keep only the most recent one (by timestamp if available, or last in array) const auditsByDate = new Map(); contentSchemaHistory.forEach(record => { let normalizedDate = null; if (record.date) { if (typeof record.date === 'string') { normalizedDate = record.date.split('T')[0]; } else if (record.date instanceof Date) { normalizedDate = record.date.toISOString().split('T')[0]; } else { normalizedDate = String(record.date).split('T')[0]; } } if (normalizedDate) { // If we already have an audit for this date, keep the one with later timestamp const existing = auditsByDate.get(normalizedDate); if (!existing || (record.timestamp && existing.timestamp && record.timestamp > existing.timestamp)) { auditsByDate.set(normalizedDate, record); } } }); // Use only the latest audit per date const deduplicatedHistory = Array.from(auditsByDate.values()); debugLog(`Deduplicated ${contentSchemaHistory.length} audits to ${deduplicatedHistory.length} unique dates (removed ${contentSchemaHistory.length - deduplicatedHistory.length} duplicate dates)`, 'info'); contentSchemaHistory = deduplicatedHistory; // CRITICAL: Validate audits for partial/failed data (for non-GSC pillars only) // Detect audits with suspiciously low page counts compared to recent audits // This prevents using partial audit data for Content/Schema and Local Entity/Service Area const validateAuditQuality = (records) => { if (records.length === 0) return records; // Sort by date to find baseline const sortedRecords = [...records].sort((a, b) => { const dateA = a.date ? (typeof a.date === 'string' ? a.date.split('T')[0] : String(a.date).split('T')[0]) : ''; const dateB = b.date ? (typeof b.date === 'string' ? b.date.split('T')[0] : String(b.date).split('T')[0]) : ''; return dateA.localeCompare(dateB); }); // Find the median page count from recent audits (last 10 audits or all if < 10) const recentAudits = sortedRecords.slice(-10); const pageCounts = recentAudits .map(r => r.schemaTotalPages || r.schema_total_pages || 0) .filter(count => count > 0) .sort((a, b) => a - b); if (pageCounts.length === 0) { debugLog(`[Audit Validation] No page count data available, skipping validation`, 'info'); return records; } // Use median as baseline (more robust than mean for outliers) const medianPageCount = pageCounts[Math.floor(pageCounts.length / 2)]; const minAcceptablePages = Math.max(10, medianPageCount * 0.1); // At least 10% of median, minimum 10 pages debugLog(`[Audit Validation] Median page count: ${medianPageCount}, Minimum acceptable: ${minAcceptablePages}`, 'info'); // Mark records with suspiciously low page counts const validatedRecords = records.map(record => { const pageCount = record.schemaTotalPages || record.schema_total_pages || 0; const isSuspicious = pageCount > 0 && pageCount < minAcceptablePages; if (isSuspicious) { // De-noise: only log each suspicious date once per page load. window.__auditValidationWarnedDates = window.__auditValidationWarnedDates || new Set(); const k = String(record.date || '').split('T')[0]; if (!window.__auditValidationWarnedDates.has(k)) { window.__auditValidationWarnedDates.add(k); debugLog(`[Audit Validation] ⚠️ Marking audit ${record.date} as suspicious: ${pageCount} pages (expected ~${medianPageCount})`, 'warn'); } record._isPartialAudit = true; record._partialReason = `Low page count: ${pageCount} pages (expected ~${medianPageCount})`; } return record; }); return validatedRecords; }; // Validate audit quality contentSchemaHistory = validateAuditQuality(contentSchemaHistory); // CRITICAL: Sort by date to ensure chronological processing // This ensures we always have a "last good value" to fall back to for partial audits contentSchemaHistory.sort((a, b) => { const dateA = a.date ? (typeof a.date === 'string' ? a.date.split('T')[0] : String(a.date).split('T')[0]) : ''; const dateB = b.date ? (typeof b.date === 'string' ? b.date.split('T')[0] : String(b.date).split('T')[0]) : ''; return dateA.localeCompare(dateB); }); // CRITICAL: Validate and smooth Authority scores // Backlink scores can drop to 0 if CSV isn't loaded, causing Authority to fluctuate wildly // Use last known good backlink score if current one is suspiciously low let lastGoodBacklinkScore = null; contentSchemaHistory.forEach(record => { const backlinkScore = record.authorityBacklinkScore || record.authority_backlink_score; if (backlinkScore !== null && backlinkScore !== undefined && backlinkScore > 0) { lastGoodBacklinkScore = backlinkScore; } else if (lastGoodBacklinkScore !== null && backlinkScore === 0) { // Backlink score dropped to 0 - likely CSV not loaded, use last good value record._useLastGoodBacklink = true; record._lastGoodBacklinkScore = lastGoodBacklinkScore; debugLog(`[Authority Validation] ⚠️ Backlink score is 0 for ${record.date}, using last good value (${lastGoodBacklinkScore})`, 'warn'); } }); // Recalculate Authority scores with smoothed backlink data contentSchemaHistory.forEach(record => { if (record._useLastGoodBacklink && record.authorityScore !== null && record.authorityScore !== undefined) { const behaviour = record.authorityBehaviourScore || record.authority_behaviour_score || 0; const ranking = record.authorityRankingScore || record.authority_ranking_score || 0; const backlinks = record._lastGoodBacklinkScore || 0; const reviews = record.authorityReviewScore || record.authority_review_score || 0; // Recalculate Authority with smoothed backlink score const recalculated = Math.round( 0.4 * behaviour + 0.2 * ranking + 0.2 * backlinks + 0.2 * reviews ); if (recalculated !== record.authorityScore) { debugLog(`[Authority Validation] Recalculated Authority for ${record.date}: ${record.authorityScore} → ${recalculated} (using last good backlink score ${backlinks})`, 'info'); record._recalculatedAuthorityScore = recalculated; // Store separately, don't overwrite original } } }); // Create maps of dates to scores from Supabase // Normalize dates to YYYY-MM-DD format (Supabase might return with timezone) // latestAuditDate is already tracked from the deduplication loop above // CRITICAL: Skip partial audits for non-GSC pillars (Content/Schema, Local Entity, Service Area) let lastGoodContentSchema = null; let lastGoodContentSchemaDate = null; let lastGoodLocalEntity = null; let lastGoodLocalEntityDate = null; let lastGoodServiceArea = null; let lastGoodServiceAreaDate = null; contentSchemaHistory.forEach(record => { // Ensure date is in YYYY-MM-DD format (strip time if present) // Handle both date strings and Date objects let normalizedDate = null; if (record.date) { if (typeof record.date === 'string') { normalizedDate = record.date.split('T')[0]; } else if (record.date instanceof Date) { normalizedDate = record.date.toISOString().split('T')[0]; } else { normalizedDate = String(record.date).split('T')[0]; } } if (normalizedDate) { // Update latestAuditDate if this date is newer if (!latestAuditDate || normalizedDate > latestAuditDate) { latestAuditDate = normalizedDate; latestAuditDateStr = normalizedDate; } // Content/Schema data - SKIP if partial audit if (record.contentSchemaScore !== null && record.contentSchemaScore !== undefined) { if (record._isPartialAudit) { debugLog(`[Audit Validation] ⚠️ Skipping Content/Schema for ${normalizedDate} (partial audit: ${record._partialReason}), using last good value (${lastGoodContentSchema} from ${lastGoodContentSchemaDate || 'none'})`, 'warn'); // Use last good value instead if (lastGoodContentSchema !== null && lastGoodContentSchema !== undefined) { contentSchemaMap.set(normalizedDate, lastGoodContentSchema); } } else { contentSchemaMap.set(normalizedDate, record.contentSchemaScore); lastGoodContentSchema = record.contentSchemaScore; lastGoodContentSchemaDate = normalizedDate; debugLog(`Mapped Content/Schema: ${normalizedDate} = ${record.contentSchemaScore}`, 'info'); } } // Business Profile data (Local Entity and Service Area) - SKIP if partial audit if (record.localEntityScore !== null && record.localEntityScore !== undefined) { if (record._isPartialAudit) { debugLog(`[Audit Validation] ⚠️ Skipping Local Entity for ${normalizedDate} (partial audit: ${record._partialReason}), using last good value (${lastGoodLocalEntity} from ${lastGoodLocalEntityDate || 'none'})`, 'warn'); // Use last good value instead if (lastGoodLocalEntity !== null && lastGoodLocalEntity !== undefined) { localEntityMap.set(normalizedDate, lastGoodLocalEntity); } } else { localEntityMap.set(normalizedDate, record.localEntityScore); lastGoodLocalEntity = record.localEntityScore; lastGoodLocalEntityDate = normalizedDate; debugLog(`Mapped Local Entity: ${normalizedDate} = ${record.localEntityScore}`, 'info'); } } if (record.serviceAreaScore !== null && record.serviceAreaScore !== undefined) { if (record._isPartialAudit) { debugLog(`[Audit Validation] ⚠️ Skipping Service Area for ${normalizedDate} (partial audit: ${record._partialReason}), using last good value (${lastGoodServiceArea} from ${lastGoodServiceAreaDate || 'none'})`, 'warn'); // Use last good value instead if (lastGoodServiceArea !== null && lastGoodServiceArea !== undefined) { serviceAreaMap.set(normalizedDate, lastGoodServiceArea); } } else { serviceAreaMap.set(normalizedDate, record.serviceAreaScore); lastGoodServiceArea = record.serviceAreaScore; lastGoodServiceAreaDate = normalizedDate; debugLog(`Mapped Service Area: ${normalizedDate} = ${record.serviceAreaScore}`, 'info'); } } // Authority data (use stored Authority score if available, calculated with new formula) // CRITICAL: Use recalculated Authority score if backlink smoothing was applied if (record.authorityScore !== null && record.authorityScore !== undefined) { // Use recalculated score if available (from backlink smoothing) const authorityScoreToUse = record._recalculatedAuthorityScore !== undefined ? record._recalculatedAuthorityScore : record.authorityScore; authorityMap.set(normalizedDate, authorityScoreToUse); if (record._recalculatedAuthorityScore !== undefined) { debugLog(`Mapped Authority (smoothed): ${normalizedDate} = ${authorityScoreToUse} (was ${record.authorityScore})`, 'info'); } else { debugLog(`Mapped Authority: ${normalizedDate} = ${authorityScoreToUse}`, 'info'); } } // Segmented Authority data (new: store segmented scores for historical tracking) if (record.authorityBySegment !== null && record.authorityBySegment !== undefined) { // authorityBySegment is a JSON object: {all: {total, behaviour, ranking, backlinks, reviews}, nonEducation: {...}, money: {...}} authorityBySegmentMap.set(normalizedDate, record.authorityBySegment); debugLog(`Mapped Authority by Segment: ${normalizedDate} = ${JSON.stringify(record.authorityBySegment)}`, 'info'); } // Visibility data (use stored Visibility score from Supabase) if (record.visibilityScore !== null && record.visibilityScore !== undefined) { visibilityMap.set(normalizedDate, record.visibilityScore); debugLog(`Mapped Visibility: ${normalizedDate} = ${record.visibilityScore}`, 'info'); } // Brand Overlay data (Phase 1: stored as brand_score) if (record.brandScore !== null && record.brandScore !== undefined) { brandOverlayMap.set(normalizedDate, record.brandScore); debugLog(`Mapped Brand Overlay: ${normalizedDate} = ${record.brandScore}`, 'info'); } } }); const hasHistoricalData = contentSchemaHistory.length > 0; if (hasHistoricalData) { debugLog(`Using ${contentSchemaHistory.length} historical audit records from Supabase`, 'info'); // Debug: Show what data we have for each pillar const contentSchemaCount = Array.from(contentSchemaMap.values()).filter(v => v !== null && v !== undefined).length; const localEntityCount = Array.from(localEntityMap.values()).filter(v => v !== null && v !== undefined).length; const serviceAreaCount = Array.from(serviceAreaMap.values()).filter(v => v !== null && v !== undefined).length; const authorityCount = Array.from(authorityMap.values()).filter(v => v !== null && v !== undefined).length; const visibilityCount = Array.from(visibilityMap.values()).filter(v => v !== null && v !== undefined).length; const brandCount = Array.from(brandOverlayMap.values()).filter(v => v !== null && v !== undefined).length; debugLog(`[Supabase Data] Content/Schema: ${contentSchemaCount} entries, Local Entity: ${localEntityCount} entries, Service Area: ${serviceAreaCount} entries, Authority: ${authorityCount} entries, Visibility: ${visibilityCount} entries, Brand: ${brandCount} entries`, 'info'); // Debug: Show sample records to understand structure if (contentSchemaHistory.length > 0) { const sampleRecord = contentSchemaHistory[0]; debugLog(`[Sample Record] Keys: ${Object.keys(sampleRecord).join(', ')}, date: ${sampleRecord.date}, contentSchemaScore: ${sampleRecord.contentSchemaScore}, localEntityScore: ${sampleRecord.localEntityScore}, serviceAreaScore: ${sampleRecord.serviceAreaScore}`, 'info'); } // Show latest dates for each pillar if (contentSchemaMap.size > 0) { const latestContentSchemaDate = Array.from(contentSchemaMap.keys()).sort().reverse()[0]; debugLog(`[Supabase Data] Latest Content/Schema date: ${latestContentSchemaDate}`, 'info'); } if (localEntityMap.size > 0) { const latestLocalEntityDate = Array.from(localEntityMap.keys()).sort().reverse()[0]; debugLog(`[Supabase Data] Latest Local Entity date: ${latestLocalEntityDate}`, 'info'); } if (serviceAreaMap.size > 0) { const latestServiceAreaDate = Array.from(serviceAreaMap.keys()).sort().reverse()[0]; debugLog(`[Supabase Data] Latest Service Area date: ${latestServiceAreaDate}`, 'info'); } } else { debugLog(`No historical data found. Will use current scores for all points.`, 'info'); } // Phase 3: Render Money Pages trend chart if (typeof renderMoneyPagesTrendChart === 'function') { setTimeout(() => { renderMoneyPagesTrendChart(contentSchemaHistory); }, 1000); // Delay to ensure DOM is ready and chart container exists } else { debugLog('⚠ renderMoneyPagesTrendChart function not found', 'warn'); } } catch (error) { debugLog(`⚠ Error fetching historical data: ${error.message}`, 'warn'); // Ensure latestAuditDateStr is still defined even if fetch fails // It's already set to todayStr as default, so it should be fine debugLog(`Using default latestAuditDateStr: ${latestAuditDateStr}`, 'info'); contentSchemaHistory = []; } } else { debugLog(`No property URL found. Cannot fetch historical data.`, 'info'); } // Get current Business Profile data for Local Entity and Service Area (fallback if no historical data) const hasLocalSignals = saved && saved.localSignals && saved.localSignals.status === 'ok' && saved.localSignals.data; const localSignalsData = hasLocalSignals ? saved.localSignals.data : null; // Calculate current Local Entity and Service Area scores from Business Profile data (if available) // These will be used as fallback when historical data is not available for a specific date let currentLocalEntity, currentServiceArea; if (hasLocalSignals && localSignalsData) { // Local Entity: NAP consistency + bonuses let baseScore = localSignalsData.napConsistencyScore || 0; if (localSignalsData.knowledgePanelDetected) { baseScore = Math.min(100, baseScore + 10); } if (localSignalsData.locations && localSignalsData.locations.length > 0) { baseScore = Math.min(100, baseScore + 5); } currentLocalEntity = clampScore(baseScore); // Service Area: based on service areas count const serviceAreasCount = localSignalsData.serviceAreas?.length || 0; if (serviceAreasCount === 0) { currentServiceArea = 0; } else if (serviceAreasCount >= 8) { // 8+ service areas = 100 (more reasonable threshold) currentServiceArea = 100; } else { // Linear scale: 1 area = 12.5 points (8 areas = 100) currentServiceArea = Math.min(100, serviceAreasCount * 12.5); } // Apply NAP consistency as a multiplier (if NAP is low, reduce service area score) if (localSignalsData.napConsistencyScore !== null && localSignalsData.napConsistencyScore < 100) { const napMultiplier = localSignalsData.napConsistencyScore / 100; currentServiceArea = Math.round(currentServiceArea * napMultiplier); } currentServiceArea = clampScore(currentServiceArea); debugLog(`Trend chart: Current Business Profile data - Local Entity=${currentLocalEntity}, Service Area=${currentServiceArea} (used as fallback)`, 'info'); } else { // Fallback: use derived calculation currentLocalEntity = null; currentServiceArea = null; debugLog('Trend chart: No Business Profile data available, will use derived calculation for dates without historical data', 'warn'); } // Calculate pillar scores for each timeseries point // For Local Entity and Service Area: use historical Business Profile data if available, // otherwise use calculated data from GSC (derived calculation) const calculatePillarFromMetrics = (position, ctr, dateStr = null, topQueries = null, backlinkMetrics = null, localSignals = null, siteReviews = null) => { // Position score (same formula as main calculation) const clampedPos = Math.max(1, Math.min(40, position)); const scale = (clampedPos - 1) / 39; const posScore = 100 - scale * 90; // CTR score (needed for Local Entity calculation and Authority fallback) // Convert ctr from percentage (0-100) to decimal (0-1), then apply formula const ctrDecimal = ctr / 100; // Convert percentage to decimal (e.g., 10% -> 0.10) const ctrScore = Math.min((ctrDecimal / 0.10) * 100, 100); // Calculate each pillar (same formulas as main calculation) const visibility = clampScore(posScore); // Authority: New 4-component model // Check if we have stored Authority component scores for this date let authority; if (dateStr && authorityMap && authorityMap.has(dateStr)) { // Use stored Authority score from Supabase (calculated with new formula) authority = authorityMap.get(dateStr); debugLog(`Authority: Using stored score (${authority}) for ${dateStr}`, 'info'); } else if (topQueries && Array.isArray(topQueries) && topQueries.length > 0) { // We have topQueries data - use full 4-component calculation const queriesForCalculation = topQueries.map(q => ({ clicks: q.clicks || 0, impressions: q.impressions || 0, ctr: (q.ctr || 0) / 100, // Convert percentage to decimal position: q.position || 0 })); const behaviourScore = computeBehaviourScore(queriesForCalculation); const rankingScore = computeRankingScore(queriesForCalculation); // Use real backlink and review scores if available (for today's date) const backlinkScore = computeBacklinkScore(backlinkMetrics); // Get review data const gbpRating = (localSignals && localSignals.status === 'ok' && localSignals.data) ? (localSignals.data.gbpRating !== null && localSignals.data.gbpRating !== undefined ? localSignals.data.gbpRating : null) : null; const gbpCount = (localSignals && localSignals.status === 'ok' && localSignals.data) ? (localSignals.data.gbpReviewCount !== null && localSignals.data.gbpReviewCount !== undefined ? localSignals.data.gbpReviewCount : null) : null; const siteRating = siteReviews?.siteRating !== null && siteReviews?.siteRating !== undefined ? siteReviews.siteRating : null; const siteCount = siteReviews?.siteReviewCount !== null && siteReviews?.siteReviewCount !== undefined ? siteReviews.siteReviewCount : null; const reviewScore = computeReviewScore({ gbpRating, gbpCount, siteRating, siteCount }); authority = clampScore( 0.4 * behaviourScore + 0.2 * rankingScore + 0.2 * backlinkScore + 0.2 * reviewScore ); debugLog(`Authority: Calculated from topQueries for ${dateStr || 'current'}: Behaviour=${behaviourScore.toFixed(1)}, Ranking=${rankingScore.toFixed(1)}, Backlinks=${backlinkScore}, Reviews=${reviewScore}, Final=${authority}`, 'info'); } else { // Fallback: Simplified Authority calculation from aggregate metrics // Estimate Behaviour and Ranking scores from aggregate metrics // Behaviour: Use aggregate CTR as proxy (simplified) const estimatedBehaviourScore = Math.min(ctrScore * 0.7, 70); // Max 70 points // Ranking: Use position score (simplified) const estimatedRankingScore = posScore * 0.6; // Max 60 points (position) + 40 (share estimate) const estimatedShareScore = 20; // Conservative estimate for top-10 share const estimatedRanking = estimatedRankingScore + estimatedShareScore; const backlinkScore = 50; // Placeholder const reviewScore = 50; // Placeholder authority = clampScore( 0.4 * estimatedBehaviourScore + 0.2 * estimatedRanking + 0.2 * backlinkScore + 0.2 * reviewScore ); debugLog(`Authority: Using simplified calculation from aggregate metrics for ${dateStr || 'current'}: Estimated Behaviour=${estimatedBehaviourScore.toFixed(1)}, Estimated Ranking=${estimatedRanking.toFixed(1)}, Final=${authority}`, 'info'); } // For Local Entity and Service Area: // 1. Check for historical Business Profile data for this specific date // 2. If no historical data, use calculated data from GSC (derived calculation) // 3. Only use current Business Profile data as last resort (for recent dates without historical data) let localEntity, serviceArea; if (dateStr) { const historicalLocalEntity = localEntityMap.get(dateStr); const historicalServiceArea = serviceAreaMap.get(dateStr); if (historicalLocalEntity !== null && historicalLocalEntity !== undefined) { // Use historical Business Profile data localEntity = historicalLocalEntity; } else { // Use calculated data from GSC (derived calculation) for historical dates localEntity = clampScore(60 + 0.3 * (posScore - 50) + 0.2 * (ctrScore - 50)); } if (historicalServiceArea !== null && historicalServiceArea !== undefined) { // Use historical Business Profile data serviceArea = historicalServiceArea; } else { // Use calculated data from GSC (derived calculation) for historical dates serviceArea = clampScore(localEntity - 5); } } else { // No date provided - use current Business Profile data if available, otherwise calculated localEntity = currentLocalEntity !== null ? currentLocalEntity : clampScore(60 + 0.3 * (posScore - 50) + 0.2 * (ctrScore - 50)); serviceArea = currentServiceArea !== null ? currentServiceArea : clampScore(localEntity - 5); } return { visibility, authority, localEntity, serviceArea }; }; // Extract data arrays from timeseries localEntityData = []; serviceAreaData = []; authorityData = []; visibilityData = []; contentSchemaData = []; brandOverlayData = []; contentSchemaDataEstimated = []; // Reset array for timeseries data const allDateObjects = []; // Store Date objects for year detection const allDates = []; debugLog(`Content/Schema map has ${contentSchemaMap.size} entries: ${Array.from(contentSchemaMap.entries()).map(([d, s]) => `${d}=${s}`).join(', ')}`, 'info'); debugLog(`Local Entity map has ${localEntityMap.size} entries: ${Array.from(localEntityMap.entries()).map(([d, s]) => `${d}=${s}`).join(', ')}`, 'info'); debugLog(`Service Area map has ${serviceAreaMap.size} entries: ${Array.from(serviceAreaMap.entries()).map(([d, s]) => `${d}=${s}`).join(', ')}`, 'info'); debugLog(`Timeseries has ${timeseries.length} points`, 'info'); // Get current audit data for today's full Authority calculation const savedAuditForTrend = loadAuditResults(); const currentTopQueries = savedAuditForTrend?.searchData?.topQueries || null; const currentBacklinkMetrics = savedAuditForTrend?.backlinkMetrics || null; const currentLocalSignals = savedAuditForTrend?.localSignals || null; // Always use correct Trustpilot snapshot (4.6, 610) - override any old cached values const currentSiteReviews = getTrustpilotSnapshot(savedAuditForTrend?.siteReviews || null); const currentScores = savedAuditForTrend?.scores; // Check if segmentation data is available and show/hide toggle const authorityBySegment = currentScores?.authority?.bySegment || null; const trendToggle = document.getElementById('trendAuthorityModeToggle'); if (trendToggle) { if (authorityBySegment) { trendToggle.style.display = 'block'; // Initialize mode if not set if (!window.trendAuthorityMode) { window.trendAuthorityMode = 'all'; } } else { trendToggle.style.display = 'none'; window.trendAuthorityMode = 'all'; // Default to all } } // Get Authority score for selected mode const getAuthorityForMode = (mode) => { if (authorityBySegment && authorityBySegment[mode]) { return authorityBySegment[mode].total || authorityBySegment[mode].score || 0; } // Fallback to all or main score if (authorityBySegment && authorityBySegment.all) { return authorityBySegment.all.total || authorityBySegment.all.score || 0; } // Legacy fallback const authObj = currentScores?.authority; if (typeof authObj === 'object' && authObj !== null) { return authObj.score || 0; } return authObj || 0; }; timeseries.forEach(point => { const pointDate = point.date; // YYYY-MM-DD format // Check if this is the latest audit date - if so, use stored scores from Supabase or current audit // Use latestAuditDateStr (from Supabase) instead of todayStr to ensure we use actual latest audit const isLatestAudit = pointDate === latestAuditDateStr; // Check if we have historical data from Supabase for Local Entity, Service Area, and Authority const historicalLocalEntity = localEntityMap.get(pointDate); const historicalServiceArea = serviceAreaMap.get(pointDate); const historicalAuthority = authorityMap.get(pointDate); // For latest audit date, use current topQueries, backlinkMetrics, and reviews for full calculation // For historical dates, pass null (will use simplified calculation) const topQueriesForDate = isLatestAudit ? currentTopQueries : null; const backlinkMetricsForDate = isLatestAudit ? currentBacklinkMetrics : null; const localSignalsForDate = isLatestAudit ? currentLocalSignals : null; const siteReviewsForDate = isLatestAudit ? currentSiteReviews : null; // Pass date to calculatePillarFromMetrics so it can check for historical data // For today, pass topQueries and other data for full calculation // For historical dates, pass null (will use simplified calculation) const pillarScores = calculatePillarFromMetrics( point.position, point.ctr, pointDate, topQueriesForDate, backlinkMetricsForDate, localSignalsForDate, siteReviewsForDate ); // Local Entity and Service Area: Use historical data from Supabase if available // These don't require GSC data, so should use latest available score for missing dates if (historicalLocalEntity !== undefined && historicalLocalEntity !== null) { localEntityData.push(historicalLocalEntity); debugLog(`Using historical Local Entity (${historicalLocalEntity}) for ${pointDate}`, 'info'); } else { // For missing dates, use the most recent available score from the map let latestAvailableLocalEntity = null; let latestAvailableDate = null; localEntityMap.forEach((score, mapDate) => { if (mapDate <= pointDate && (latestAvailableDate === null || mapDate > latestAvailableDate)) { latestAvailableDate = mapDate; latestAvailableLocalEntity = score; } }); if (latestAvailableLocalEntity !== null && latestAvailableLocalEntity !== undefined) { localEntityData.push(latestAvailableLocalEntity); debugLog(`Using latest available Local Entity (${latestAvailableLocalEntity} from ${latestAvailableDate}) for ${pointDate}`, 'info'); } else if (isLatestAudit) { // For latest audit date, use current score const savedAuditForLocal = loadAuditResultsSync(); const currentLocalEntity = savedAuditForLocal?.scores?.localEntity; if (currentLocalEntity !== null && currentLocalEntity !== undefined && currentLocalEntity > 0) { localEntityData.push(currentLocalEntity); debugLog(`Using current Local Entity (${currentLocalEntity}) for ${pointDate} (latest audit)`, 'info'); } else { localEntityData.push(null); debugLog(`No Local Entity data for ${pointDate}`, 'warn'); } } else { localEntityData.push(null); debugLog(`No Local Entity data for ${pointDate} (no historical or current data)`, 'info'); } } if (historicalServiceArea !== undefined && historicalServiceArea !== null) { serviceAreaData.push(historicalServiceArea); debugLog(`Using historical Service Area (${historicalServiceArea}) for ${pointDate}`, 'info'); } else { // For missing dates, use the most recent available score from the map let latestAvailableServiceArea = null; let latestAvailableDate = null; serviceAreaMap.forEach((score, mapDate) => { if (mapDate <= pointDate && (latestAvailableDate === null || mapDate > latestAvailableDate)) { latestAvailableDate = mapDate; latestAvailableServiceArea = score; } }); if (latestAvailableServiceArea !== null && latestAvailableServiceArea !== undefined) { serviceAreaData.push(latestAvailableServiceArea); debugLog(`Using latest available Service Area (${latestAvailableServiceArea} from ${latestAvailableDate}) for ${pointDate}`, 'info'); } else if (isLatestAudit) { // For latest audit date, use current score const savedAuditForService = loadAuditResultsSync(); const currentServiceArea = savedAuditForService?.scores?.serviceArea; if (currentServiceArea !== null && currentServiceArea !== undefined && currentServiceArea > 0) { serviceAreaData.push(currentServiceArea); debugLog(`Using current Service Area (${currentServiceArea}) for ${pointDate} (latest audit)`, 'info'); } else { serviceAreaData.push(null); debugLog(`No Service Area data for ${pointDate}`, 'warn'); } } else { serviceAreaData.push(null); debugLog(`No Service Area data for ${pointDate} (no historical or current data)`, 'info'); } } // Use Authority score based on selected mode, prioritizing segmented historical data when available // Check if we have segmented Authority data for this date in Supabase const historicalAuthorityBySegment = authorityBySegmentMap.get(pointDate); const selectedMode = window.trendAuthorityMode || 'all'; if (historicalAuthorityBySegment && historicalAuthorityBySegment[selectedMode]) { // Use segmented Authority score from Supabase for the selected mode const modeScore = historicalAuthorityBySegment[selectedMode].total || historicalAuthorityBySegment[selectedMode].score || historicalAuthorityBySegment[selectedMode]; authorityData.push(modeScore); debugLog(`Using historical segmented Authority (${modeScore}) for ${pointDate} (mode: ${selectedMode})`, 'info'); } else if (isLatestAudit) { // For latest audit date, prioritize stored Authority score from current audit const savedAuditForAuthority = loadAuditResultsSync(); const currentAuthority = savedAuditForAuthority?.scores?.authority; let authorityScoreToUse = null; // Try to get Authority score based on selected mode if (authorityBySegment) { authorityScoreToUse = getAuthorityForMode(selectedMode); } else if (typeof currentAuthority === 'object' && currentAuthority !== null) { authorityScoreToUse = currentAuthority.score || currentAuthority; } else if (typeof currentAuthority === 'number') { authorityScoreToUse = currentAuthority; } if (authorityScoreToUse !== null && authorityScoreToUse !== undefined && authorityScoreToUse > 0) { authorityData.push(authorityScoreToUse); debugLog(`Using current stored Authority (${authorityScoreToUse}) for ${pointDate} (mode: ${selectedMode}, latest audit)`, 'info'); } else { // Fallback to calculated score if stored score not available const authScore = typeof pillarScores.authority === 'object' ? pillarScores.authority.score : pillarScores.authority; authorityData.push(authScore); debugLog(`Using calculated Authority (${authScore}) for ${pointDate} (stored score not available)`, 'info'); } } else if (historicalAuthority !== undefined && historicalAuthority !== null) { // Fallback: use legacy historical Authority from Supabase (single value, not segmented) authorityData.push(historicalAuthority); debugLog(`Using legacy historical Authority (${historicalAuthority}) for ${pointDate}`, 'info'); } else { // For missing dates, use the most recent available score from Supabase (prevents artificial dips) let latestAvailableAuthority = null; let latestAvailableAuthorityDate = null; // Prefer segmented historical Authority if available (match selected mode) authorityBySegmentMap.forEach((seg, mapDate) => { if (mapDate <= pointDate && (latestAvailableAuthorityDate === null || mapDate > latestAvailableAuthorityDate)) { if (seg && seg[selectedMode] !== undefined && seg[selectedMode] !== null) { const v = seg[selectedMode].total || seg[selectedMode].score || seg[selectedMode]; latestAvailableAuthorityDate = mapDate; latestAvailableAuthority = v; } } }); // Fallback to legacy Authority map if segmented isn't available if (latestAvailableAuthority === null || latestAvailableAuthority === undefined) { authorityMap.forEach((score, mapDate) => { if (mapDate <= pointDate && (latestAvailableAuthorityDate === null || mapDate > latestAvailableAuthorityDate)) { latestAvailableAuthorityDate = mapDate; latestAvailableAuthority = score; } }); } if (latestAvailableAuthority !== null && latestAvailableAuthority !== undefined) { authorityData.push(latestAvailableAuthority); debugLog(`Using latest available Authority (${latestAvailableAuthority} from ${latestAvailableAuthorityDate}) for ${pointDate}`, 'info'); } else { // Final fallback: calculate from GSC metrics (only if we have valid data) const hasValidGscData = point.position && point.position > 0 && point.ctr !== null && point.ctr >= 0; if (hasValidGscData) { const authScore = typeof pillarScores.authority === 'object' ? pillarScores.authority.score : pillarScores.authority; authorityData.push(authScore); debugLog(`Using calculated Authority (${authScore}) for ${pointDate}`, 'info'); } else { authorityData.push(null); debugLog(`No valid GSC data for Authority on ${pointDate} (position=${point.position}, ctr=${point.ctr}) - using null`, 'warn'); } } } // Visibility data (check historical data from Supabase FIRST, then use current audit score, then calculate only if missing and valid GSC data) const historicalVisibility = visibilityMap.get(pointDate); if (historicalVisibility !== undefined && historicalVisibility !== null) { // Use stored score from database (fast - no calculation needed) visibilityData.push(historicalVisibility); debugLog(`Using stored Visibility (${historicalVisibility}) for ${pointDate}`, 'info'); } else if (isLatestAudit) { // For latest audit date, prioritize stored Visibility score from current audit const savedAuditForVisibility = loadAuditResultsSync(); const currentVisibility = savedAuditForVisibility?.scores?.visibility; if (currentVisibility !== null && currentVisibility !== undefined && currentVisibility > 0) { visibilityData.push(currentVisibility); debugLog(`Using current stored Visibility (${currentVisibility}) for ${pointDate} (latest audit)`, 'info'); } else { // Fallback to calculated score if stored score not available visibilityData.push(pillarScores.visibility); debugLog(`Using calculated Visibility (${pillarScores.visibility}) for ${pointDate} (stored score not available)`, 'info'); } } else { // For past dates: calculate from GSC data as fallback (only if stored score missing AND valid GSC data) const hasValidGscData = point.position && point.position > 0 && point.ctr !== null && point.ctr >= 0; if (hasValidGscData) { visibilityData.push(pillarScores.visibility); debugLog(`Calculated Visibility (${pillarScores.visibility}) for ${pointDate} - stored score missing, using GSC fallback`, 'info'); } else { visibilityData.push(null); debugLog(`No valid GSC data for Visibility on ${pointDate} (position=${point.position}, ctr=${point.ctr}) - using null`, 'warn'); } } // Check if we have real historical data for this date // Content/Schema doesn't require GSC data, so should always have values // NOTE: 0 is a valid score, so check for null/undefined only const realScore = contentSchemaMap.get(pointDate); if (realScore !== undefined && realScore !== null) { // We have real data for this date (including 0, which is valid) contentSchemaData.push(realScore); contentSchemaDataEstimated.push(null); // No estimated value debugLog(`Content/Schema: Added ${realScore} for ${pointDate} from Supabase`, 'info'); } else { // For missing dates, use the most recent available score from the map let latestAvailableContentSchema = null; let latestAvailableDate = null; contentSchemaMap.forEach((score, mapDate) => { if (mapDate <= pointDate && (latestAvailableDate === null || mapDate > latestAvailableDate)) { latestAvailableDate = mapDate; latestAvailableContentSchema = score; } }); // If no historical data found, use current score for latest audit date // NOTE: 0 is a valid score, so check for null/undefined only if (latestAvailableContentSchema !== null && latestAvailableContentSchema !== undefined) { contentSchemaData.push(latestAvailableContentSchema); contentSchemaDataEstimated.push(null); debugLog(`Content/Schema: Added ${latestAvailableContentSchema} for ${pointDate} from latest available (${latestAvailableDate})`, 'info'); } else if (isLatestAudit) { // For latest audit date, try multiple sources const savedAuditForContentSchema = loadAuditResultsSync(); const savedContentSchema = savedAuditForContentSchema?.scores?.contentSchema; // NOTE: 0 is a valid score, so check for null/undefined only const scoreToUse = (currentContentSchema !== null && currentContentSchema !== undefined) ? currentContentSchema : (savedContentSchema !== null && savedContentSchema !== undefined) ? savedContentSchema : null; if (scoreToUse !== null && scoreToUse !== undefined) { contentSchemaData.push(scoreToUse); contentSchemaDataEstimated.push(null); debugLog(`Content/Schema: Added ${scoreToUse} for ${pointDate} from ${scoreToUse === currentContentSchema ? 'current audit' : 'saved audit'} (latest)`, 'info'); } else { contentSchemaData.push(null); contentSchemaDataEstimated.push(null); debugLog(`Content/Schema: No valid score for ${pointDate} (isLatestAudit=${isLatestAudit}, currentContentSchema=${currentContentSchema}, savedContentSchema=${savedContentSchema})`, 'warn'); } } else { // No real data for this date - use null (don't show estimated line) contentSchemaData.push(null); contentSchemaDataEstimated.push(null); debugLog(`Content/Schema: No data for ${pointDate} (not latest audit, map has ${contentSchemaMap.size} entries)`, 'info'); } } // Brand Overlay data (prioritize Supabase; if missing for a date, carry-forward latest available) const historicalBrandOverlay = brandOverlayMap.get(pointDate); if (historicalBrandOverlay !== undefined && historicalBrandOverlay !== null) { brandOverlayData.push(historicalBrandOverlay); debugLog(`✓ Added Brand Overlay score (${historicalBrandOverlay}) for ${pointDate} from Supabase (historical)`, 'info'); } else { let latestAvailableBrand = null; let latestAvailableBrandDate = null; brandOverlayMap.forEach((score, mapDate) => { if (mapDate <= pointDate && (latestAvailableBrandDate === null || mapDate > latestAvailableBrandDate)) { latestAvailableBrandDate = mapDate; latestAvailableBrand = score; } }); if (latestAvailableBrand !== null && latestAvailableBrand !== undefined) { brandOverlayData.push(latestAvailableBrand); debugLog(`✓ Using latest available Brand Overlay (${latestAvailableBrand} from ${latestAvailableBrandDate}) for ${pointDate}`, 'info'); } else if (isLatestAudit && currentBrandOverlay !== null) { brandOverlayData.push(currentBrandOverlay); debugLog(`⚠ Using current Brand Overlay score (${currentBrandOverlay}) for ${pointDate} - no historical data found in Supabase`, 'warn'); } else { // Final fallback: estimate from GSC metrics const historicalRecord = contentSchemaHistory.find(r => { const rDate = typeof r.date === 'string' ? r.date.split('T')[0] : (r.date instanceof Date ? r.date.toISOString().split('T')[0] : String(r.date).split('T')[0]); return rDate === pointDate; }); const historicalReviewScore = historicalRecord?.authorityReviewScore; const historicalEntityScore = historicalLocalEntity !== undefined && historicalLocalEntity !== null ? historicalLocalEntity : null; const reviewScore = historicalReviewScore !== undefined && historicalReviewScore !== null ? historicalReviewScore : (currentScores?.authorityComponents?.reviews || currentScores?.authority?.bySegment?.all?.reviews || 0); const entityScore = historicalEntityScore !== null && historicalEntityScore !== undefined ? historicalEntityScore : (currentScores?.localEntity || 0); const hasValidGscData = point.position && point.position > 0 && point.ctr !== null && point.ctr >= 0; if (!hasValidGscData) { brandOverlayData.push(null); debugLog(`No valid GSC data for Brand Overlay on ${pointDate} (position=${point.position}, ctr=${point.ctr}) - using null`, 'warn'); } else { const position = point.position || 40; const ctr = point.ctr || 0; const estimatedBrandCtr = Math.min(ctr * 1.5, 0.4); const estimatedBrandPosition = Math.max(position * 0.7, 1); const estimatedBrandShare = 0.02; try { const fallbackBrandOverlay = computeBrandOverlay({ brandQueryShare: estimatedBrandShare, brandCtr: estimatedBrandCtr, brandAvgPosition: estimatedBrandPosition, reviewScore: reviewScore, entityScore: entityScore }); brandOverlayData.push(fallbackBrandOverlay.score); debugLog(`✓ Calculated fallback Brand Overlay score (${fallbackBrandOverlay.score}) for ${pointDate} from GSC timeseries data`, 'info'); } catch (e) { debugLog(`⚠ Error calculating fallback Brand Overlay for ${pointDate}: ${e.message}`, 'warn'); brandOverlayData.push(null); } } } } // Format date for chart (timeseries dates are YYYY-MM-DD) const dateObj = new Date(point.date); allDateObjects.push(dateObj); allDates.push(dateObj.toLocaleDateString('en-GB', dateFormat)); }); // Log Content/Schema data array summary after timeseries loop const contentSchemaValid = contentSchemaData.filter(v => v !== null && v !== undefined); debugLog(`Content/Schema data after timeseries: ${contentSchemaValid.length} valid values out of ${contentSchemaData.length} total. Values: ${contentSchemaValid.join(', ')}`, 'info'); // Fill in missing dates between last GSC date and latest audit date // GSC data is delayed by 2-3 days, but we want to show the latest audit date // Reuse 'today' and 'todayStr' variables (already declared above) today = new Date(); today.setHours(0, 0, 0, 0); // Normalize to start of day todayStr = today.toISOString().split('T')[0]; const lastTimeseriesDate = timeseries.length > 0 ? timeseries[timeseries.length - 1].date : null; // Store last timeseries date globally so displayDashboard can use it for Data Date window.lastGscTimeseriesDate = lastTimeseriesDate; if (lastTimeseriesDate) { debugLog(`Stored last GSC timeseries date globally: ${lastTimeseriesDate}`, 'info'); } // Determine actual last GSC data date // CRITICAL: Only use dates from maps that are <= lastTimeseriesDate (actual GSC data) // Never forward-fill GSC data beyond the last actual GSC timeseries date // Maps may have dates after lastTimeseriesDate (from audits), but those aren't from actual GSC data const allGscDates = new Set(); // Add all dates from timeseries (actual GSC API data) timeseries.forEach(point => { if (point.date) { allGscDates.add(point.date); } }); // Add dates from historical maps ONLY if they're <= lastTimeseriesDate // This ensures we only use GSC-derived scores that are based on actual GSC data if (lastTimeseriesDate) { visibilityMap.forEach((score, date) => { if (date && date <= lastTimeseriesDate && score !== null && score !== undefined) { allGscDates.add(date); } }); authorityMap.forEach((score, date) => { if (date && date <= lastTimeseriesDate && score !== null && score !== undefined) { allGscDates.add(date); } }); brandOverlayMap.forEach((score, date) => { if (date && date <= lastTimeseriesDate && score !== null && score !== undefined) { allGscDates.add(date); } }); } // Use the latest date from timeseries OR valid map dates (all <= lastTimeseriesDate) // This is the definitive last GSC data date - never forward-fill beyond this let lastGscDateForRange = lastTimeseriesDate; // Default to timeseries date (most accurate) if (allGscDates.size > 0) { const sortedDates = Array.from(allGscDates).sort(); const latestDate = sortedDates[sortedDates.length - 1]; // Use the later of: lastTimeseriesDate or latest valid map date // But latestDate should never be > lastTimeseriesDate due to filtering above lastGscDateForRange = latestDate > lastTimeseriesDate ? lastTimeseriesDate : latestDate; debugLog(`Last GSC date: ${lastGscDateForRange} (timeseries: ${lastTimeseriesDate}, valid map dates: ${sortedDates.length})`, 'info'); if (sortedDates.length > 0) { debugLog(`GSC date range: ${sortedDates[0]} to ${lastGscDateForRange}`, 'info'); } } else { debugLog(`No GSC dates found, using lastTimeseriesDate: ${lastGscDateForRange}`, 'warn'); } // Ensure latest audit date is included even if not in timeseries // Add it to the chart if it's after the last GSC date if (latestAuditDateStr && lastGscDateForRange && latestAuditDateStr > lastGscDateForRange) { debugLog(`Latest audit date (${latestAuditDateStr}) is after last GSC date (${lastGscDateForRange}), will add to chart`, 'info'); } // Check if we're missing recent dates (likely due to GSC delay) let hasRecentMissingDates = false; if (lastTimeseriesDate) { const lastDate = new Date(lastTimeseriesDate); lastDate.setHours(0, 0, 0, 0); const daysSinceLastData = Math.floor((today - lastDate) / (1000 * 60 * 60 * 24)); hasRecentMissingDates = daysSinceLastData > 1; // More than 1 day gap indicates GSC delay // Show a note about GSC delay if we're missing recent dates if (hasRecentMissingDates) { const trendCanvas = document.getElementById('trendChart'); if (trendCanvas && trendCanvas.parentElement) { // Check if note already exists const existingNote = trendCanvas.parentElement.querySelector('.gsc-delay-note'); if (!existingNote) { const noteDiv = document.createElement('div'); noteDiv.className = 'gsc-delay-note'; noteDiv.style.cssText = 'background: #e0f2fe; padding: 0.75rem; border-radius: 4px; border-left: 3px solid #0284c7; margin-bottom: 1rem; font-size: 0.85rem; color: #0c4a6e;'; noteDiv.innerHTML = `Note: Google Search Console data is typically delayed by 2-3 days. Recent dates (last ${daysSinceLastData} day${daysSinceLastData > 1 ? 's' : ''}) may show no data until GSC updates.`; trendCanvas.parentElement.insertBefore(noteDiv, trendCanvas); } } } } // Always extend chart to latestAuditDateStr (or today) for non-GSC pillars // GSC pillars will only show data up to lastGscDateForRange // CRITICAL: Use the last date from the timeseries array (what's already in the chart) // NOT from the maps, because the maps may have dates that aren't in the chart yet const lastTimeseriesDateStr = timeseries.length > 0 ? timeseries[timeseries.length - 1].date : null; // Find the latest date from all maps for fallback/validation const allMapDates = new Set(); contentSchemaMap.forEach((score, date) => allMapDates.add(date)); localEntityMap.forEach((score, date) => allMapDates.add(date)); serviceAreaMap.forEach((score, date) => allMapDates.add(date)); visibilityMap.forEach((score, date) => allMapDates.add(date)); authorityMap.forEach((score, date) => allMapDates.add(date)); brandOverlayMap.forEach((score, date) => allMapDates.add(date)); const latestMapDateStr = allMapDates.size > 0 ? Array.from(allMapDates).sort().reverse()[0] : null; // Use the last date from timeseries (what's already in the chart) as the starting point // This ensures we fill dates from the last timeseries date to the latest audit date const lastDateInChartStr = lastTimeseriesDateStr || (timeseries.length === 0 && latestMapDateStr ? latestMapDateStr : null); const targetDateStr = latestAuditDateStr || latestMapDateStr || today.toISOString().split('T')[0]; debugLog(`Chart extension: lastDateInChart=${lastDateInChartStr}, targetDate=${targetDateStr}, latestAuditDateStr=${latestAuditDateStr}, latestMapDate=${latestMapDateStr}`, 'info'); debugLog(`Last GSC date: ${lastGscDateForRange} (from all sources, timeseries last: ${lastTimeseriesDate})`, 'info'); debugLog(`Content/Schema map has ${contentSchemaMap.size} entries before filling missing dates`, 'info'); debugLog(`Content/Schema map entries: ${Array.from(contentSchemaMap.entries()).map(([d, s]) => `${d}=${s}`).join(', ')}`, 'info'); // Always fill dates from last date in chart to latest audit date // Use string comparison to ensure we extend the chart if (lastDateInChartStr && targetDateStr) { const dateComparison = lastDateInChartStr.localeCompare(targetDateStr); if (dateComparison < 0) { const lastDate = new Date(lastDateInChartStr); lastDate.setHours(0, 0, 0, 0); const currentDate = new Date(lastDate); currentDate.setDate(currentDate.getDate() + 1); // Start from day after last date debugLog(`Filling missing dates from ${currentDate.toISOString().split('T')[0]} to ${targetDateStr} (latest audit: ${latestAuditDateStr})`, 'info'); while (currentDate.toISOString().split('T')[0] <= targetDateStr) { const dateStr = currentDate.toISOString().split('T')[0]; const dateObj = new Date(dateStr); allDateObjects.push(dateObj); allDates.push(dateObj.toLocaleDateString('en-GB', dateFormat)); // For GSC-based pillars (Authority, Visibility), only use data from Supabase if it exists for that specific date // These rely on GSC data being fetched and stored, so NO forward-filling // CRITICAL: Only show data for dates <= lastGscDateForRange (last GSC data date from maps or timeseries) // Never show data for dates after lastGscDateForRange, even if an audit was run on that date const isDateWithinGscRange = lastGscDateForRange && dateStr <= lastGscDateForRange; const historicalVisibility = visibilityMap.get(dateStr); const historicalAuthority = authorityMap.get(dateStr); // Visibility: only use historical data from Supabase if date is within GSC data range if (isDateWithinGscRange && historicalVisibility !== undefined && historicalVisibility !== null) { visibilityData.push(historicalVisibility); debugLog(`✓ Added Visibility score (${historicalVisibility}) for ${dateStr} from Supabase`, 'info'); } else { // No data for dates after last GSC data date - GSC-based metrics require real GSC data visibilityData.push(null); if (!isDateWithinGscRange) { debugLog(`No Visibility data for ${dateStr} (date is after last GSC data date: ${lastGscDateForRange})`, 'info'); } else { debugLog(`No Visibility data for ${dateStr} (no historical audit data)`, 'info'); } } // Authority: only use historical data from Supabase if date is within GSC data range if (isDateWithinGscRange && historicalAuthority !== undefined && historicalAuthority !== null) { authorityData.push(historicalAuthority); debugLog(`✓ Added Authority score (${historicalAuthority}) for ${dateStr} from Supabase`, 'info'); } else { // No data for dates after last GSC data date - GSC-based metrics require real GSC data authorityData.push(null); if (!isDateWithinGscRange) { debugLog(`No Authority data for ${dateStr} (date is after last GSC data date: ${lastGscDateForRange})`, 'info'); } else { debugLog(`No Authority data for ${dateStr} (no historical audit data)`, 'info'); } } // For Local Entity and Service Area, check if we have historical data from Supabase // These don't require GSC data, so should always have values up to today const historicalLocalEntity = localEntityMap.get(dateStr); const historicalServiceArea = serviceAreaMap.get(dateStr); // Local Entity: use historical data, or latest available, or current score for today if (historicalLocalEntity !== undefined && historicalLocalEntity !== null) { localEntityData.push(historicalLocalEntity); debugLog(`✓ Added Local Entity score (${historicalLocalEntity}) for ${dateStr} from Supabase`, 'info'); } else { // For missing dates, use the most recent available score from the map let latestAvailableLocalEntity = null; let latestAvailableDate = null; localEntityMap.forEach((score, mapDate) => { if (mapDate <= dateStr && (latestAvailableDate === null || mapDate > latestAvailableDate)) { latestAvailableDate = mapDate; latestAvailableLocalEntity = score; } }); // If no historical data found, use current score for today/latest audit if (latestAvailableLocalEntity !== null && latestAvailableLocalEntity !== undefined) { localEntityData.push(latestAvailableLocalEntity); debugLog(`✓ Added Local Entity score (${latestAvailableLocalEntity}) for ${dateStr} from latest available (${latestAvailableDate})`, 'info'); } else if (dateStr === latestAuditDateStr || dateStr >= todayStr) { const savedAuditForLocal = loadAuditResultsSync(); const currentLocalEntity = savedAuditForLocal?.scores?.localEntity; if (currentLocalEntity !== null && currentLocalEntity !== undefined && currentLocalEntity > 0) { localEntityData.push(currentLocalEntity); debugLog(`✓ Added Local Entity score (${currentLocalEntity}) for ${dateStr} from current audit`, 'info'); } else { localEntityData.push(null); debugLog(`No Local Entity data for ${dateStr}`, 'warn'); } } else { localEntityData.push(null); debugLog(`No Local Entity data for ${dateStr} (no historical or current data)`, 'info'); } } // Service Area: use historical data, or latest available, or current score for today if (historicalServiceArea !== undefined && historicalServiceArea !== null) { serviceAreaData.push(historicalServiceArea); debugLog(`✓ Added Service Area score (${historicalServiceArea}) for ${dateStr} from Supabase`, 'info'); } else { // For missing dates, use the most recent available score from the map let latestAvailableServiceArea = null; let latestAvailableDate = null; serviceAreaMap.forEach((score, mapDate) => { if (mapDate <= dateStr && (latestAvailableDate === null || mapDate > latestAvailableDate)) { latestAvailableDate = mapDate; latestAvailableServiceArea = score; } }); // If no historical data found, use current score for today/latest audit if (latestAvailableServiceArea !== null && latestAvailableServiceArea !== undefined) { serviceAreaData.push(latestAvailableServiceArea); debugLog(`✓ Added Service Area score (${latestAvailableServiceArea}) for ${dateStr} from latest available (${latestAvailableDate})`, 'info'); } else if (dateStr === latestAuditDateStr || dateStr >= todayStr) { const savedAuditForService = loadAuditResultsSync(); const currentServiceArea = savedAuditForService?.scores?.serviceArea; if (currentServiceArea !== null && currentServiceArea !== undefined && currentServiceArea > 0) { serviceAreaData.push(currentServiceArea); debugLog(`✓ Added Service Area score (${currentServiceArea}) for ${dateStr} from current audit`, 'info'); } else { serviceAreaData.push(null); debugLog(`No Service Area data for ${dateStr}`, 'warn'); } } else { serviceAreaData.push(null); debugLog(`No Service Area data for ${dateStr} (no historical or current data)`, 'info'); } } // For Content/Schema, use latest audit score for dates after last GSC date // This ensures we show the latest audit data instead of intermediate values const isAfterLastTimeseries = lastGscDateForRange && dateStr > lastGscDateForRange; if (isAfterLastTimeseries && dateStr <= latestAuditDateStr) { // For dates after last timeseries but up to latest audit, use latest audit score (forward-fill) const savedAuditForContentSchema = loadAuditResultsSync(); const savedContentSchema = savedAuditForContentSchema?.scores?.contentSchema; // NOTE: 0 is a valid score, so check for null/undefined only const scoreToUse = (currentContentSchema !== null && currentContentSchema !== undefined) ? currentContentSchema : (savedContentSchema !== null && savedContentSchema !== undefined) ? savedContentSchema : null; if (scoreToUse !== null && scoreToUse !== undefined) { contentSchemaData.push(scoreToUse); contentSchemaDataEstimated.push(null); debugLog(`✓ Added Content/Schema score (${scoreToUse}) for ${dateStr} from latest audit (forward-filled from ${latestAuditDateStr})`, 'info'); } else { // Fallback: use latest available from map let latestAvailableContentSchema = null; let latestAvailableDate = null; contentSchemaMap.forEach((score, mapDate) => { if (mapDate <= latestAuditDateStr && (latestAvailableDate === null || mapDate > latestAvailableDate)) { latestAvailableDate = mapDate; latestAvailableContentSchema = score; } }); if (latestAvailableContentSchema !== null && latestAvailableContentSchema !== undefined) { contentSchemaData.push(latestAvailableContentSchema); contentSchemaDataEstimated.push(null); debugLog(`✓ Added Content/Schema score (${latestAvailableContentSchema}) for ${dateStr} from latest available (${latestAvailableDate})`, 'info'); } else { contentSchemaData.push(null); contentSchemaDataEstimated.push(null); debugLog(`No Content/Schema data for ${dateStr}`, 'warn'); } } } else { // For dates within timeseries range, use actual database values const realScore = contentSchemaMap.get(dateStr); if (realScore !== undefined && realScore !== null) { // We have real data for this date (including 0, which is valid) contentSchemaData.push(realScore); contentSchemaDataEstimated.push(null); debugLog(`✓ Added Content/Schema score (${realScore}) for ${dateStr} from Supabase`, 'info'); } else { // For missing dates, use the most recent available score from the map let latestAvailableContentSchema = null; let latestAvailableDate = null; contentSchemaMap.forEach((score, mapDate) => { if (mapDate <= dateStr && (latestAvailableDate === null || mapDate > latestAvailableDate)) { latestAvailableDate = mapDate; latestAvailableContentSchema = score; } }); // If no historical data found, use current score for today/latest audit if (latestAvailableContentSchema !== null && latestAvailableContentSchema !== undefined) { contentSchemaData.push(latestAvailableContentSchema); contentSchemaDataEstimated.push(null); debugLog(`✓ Added Content/Schema score (${latestAvailableContentSchema}) for ${dateStr} from latest available (${latestAvailableDate})`, 'info'); } else if (dateStr === latestAuditDateStr || dateStr >= todayStr) { const savedAuditForContentSchema = loadAuditResultsSync(); const savedContentSchema = savedAuditForContentSchema?.scores?.contentSchema; const scoreToUse = (currentContentSchema !== null && currentContentSchema !== undefined) ? currentContentSchema : (savedContentSchema !== null && savedContentSchema !== undefined) ? savedContentSchema : null; if (scoreToUse !== null && scoreToUse !== undefined) { contentSchemaData.push(scoreToUse); contentSchemaDataEstimated.push(null); debugLog(`✓ Added Content/Schema score (${scoreToUse}) for ${dateStr} from current/saved audit`, 'info'); } else { contentSchemaData.push(null); contentSchemaDataEstimated.push(null); debugLog(`No Content/Schema data for ${dateStr} (latest date but no score available)`, 'warn'); } } else { // No real data - use null (don't show estimated) contentSchemaData.push(null); contentSchemaDataEstimated.push(null); debugLog(`No Content/Schema data for ${dateStr} (no historical or current data)`, 'info'); } } } // Brand Overlay data (check historical data from Supabase) // Brand Overlay is GSC-based, so only show data for dates <= lastTimeseriesDate const historicalBrandOverlay = brandOverlayMap.get(dateStr); if (isDateWithinGscRange && historicalBrandOverlay !== undefined && historicalBrandOverlay !== null) { brandOverlayData.push(historicalBrandOverlay); debugLog(`✓ Added Brand Overlay score (${historicalBrandOverlay}) for ${dateStr} from Supabase`, 'info'); } else { // No data for dates after last GSC data date - Brand Overlay is GSC-based brandOverlayData.push(null); if (!isDateWithinGscRange) { debugLog(`No Brand Overlay data for ${dateStr} (date is after last GSC data date: ${lastGscDateForRange})`, 'info'); } else { debugLog(`No Brand Overlay data for ${dateStr} (no historical audit data)`, 'info'); } } // Move to next day currentDate.setDate(currentDate.getDate() + 1); } debugLog(`Filled missing dates: added ${allDateObjects.length - timeseries.length} additional dates`, 'info'); } else { debugLog(`No dates to fill: lastDateInChart=${lastDateInChartStr}, targetDate=${targetDateStr}`, 'info'); } } else { debugLog(`Cannot fill dates: lastDateInChart=${lastDateInChartStr}, targetDate=${targetDateStr}`, 'warn'); } debugLog(`Chart date range: ${allDates[0]} to ${allDates[allDates.length - 1]} (${allDates.length} total dates)`, 'info'); debugLog(`Latest audit date: ${latestAuditDateStr}`, 'info'); debugLog(`Chart labels (first 5, last 5): ${allDates.slice(0, 5).join(', ')} ... ${allDates.slice(-5).join(', ')}`, 'info'); if (timeseries.length === 0) { // No timeseries data at all - just add latest audit date or today const targetDateStr = latestAuditDateStr || todayStr; const targetDateObj = new Date(targetDateStr); allDateObjects.push(targetDateObj); allDates.push(targetDateObj.toLocaleDateString('en-GB', dateFormat)); // For GSC-based pillars (Authority, Visibility), use current audit data if available if (savedAuditForTrend) { const currentSearchData = savedAuditForTrend?.searchData; const currentScores = savedAuditForTrend?.scores; if (currentSearchData) { // Calculate Visibility from current position const currentPosition = currentSearchData.averagePosition || 40; const clampedPos = Math.max(1, Math.min(40, currentPosition)); const scale = (clampedPos - 1) / 39; const posScore = 100 - scale * 90; const visibility = clampScore(posScore); visibilityData.push(visibility); debugLog(`✓ Added Visibility score (${visibility}) for today from current audit`, 'info'); // Use current Authority score if available const currentAuthority = currentScores?.authority || null; if (currentAuthority !== null && currentAuthority !== undefined) { authorityData.push(currentAuthority); debugLog(`✓ Added Authority score (${currentAuthority}) for today from current audit`, 'info'); } else { // Fallback: calculate Authority from current data const pillarScores = calculatePillarFromMetrics( currentPosition, currentSearchData.ctr || 0, todayStr, currentTopQueries, currentBacklinkMetrics, currentLocalSignals, currentSiteReviews ); const authScore = typeof pillarScores.authority === 'object' ? pillarScores.authority.score : pillarScores.authority; authorityData.push(authScore); debugLog(`✓ Calculated Authority score (${authScore}) for today from current audit data`, 'info'); } } else { // No current search data - use null authorityData.push(null); visibilityData.push(null); } } else { // No saved audit data - use null authorityData.push(null); visibilityData.push(null); } // For today, use current Business Profile data if available, otherwise calculated if (currentLocalEntity !== null) { localEntityData.push(currentLocalEntity); } else { localEntityData.push(null); } if (currentServiceArea !== null) { serviceAreaData.push(currentServiceArea); } else { serviceAreaData.push(null); } // For Content/Schema, check if we have real data for today const todayRealScore = contentSchemaMap.get(todayStr); if (todayRealScore !== undefined) { contentSchemaData.push(todayRealScore); contentSchemaDataEstimated.push(null); debugLog(`✓ Added today's Content/Schema score (${todayRealScore}) from Supabase`, 'info'); } else { contentSchemaData.push(null); contentSchemaDataEstimated.push(currentContentSchema); debugLog(`Using current Content/Schema score (${currentContentSchema}) as estimate for today`, 'info'); } // For Brand Overlay, use current score if available if (currentBrandOverlay !== null) { brandOverlayData.push(currentBrandOverlay); debugLog(`✓ Added Brand Overlay score (${currentBrandOverlay}) for today from current audit`, 'info'); } else { brandOverlayData.push(null); } } // Apply label spacing to dates with year detection (show every labelStep-th label) let lastVisibleYear = null; chartDates = allDates.map((d, i) => { if (i % labelStep === 0 || i === allDates.length - 1) { const currentYear = allDateObjects[i].getFullYear(); // Add year if it changed from the last visible label if (lastVisibleYear !== null && currentYear !== lastVisibleYear) { lastVisibleYear = currentYear; return `${d} ${currentYear}`; } lastVisibleYear = currentYear; return d; } return ''; // Empty string for labels we don't want to show }); // Store date objects for timeseries data chartDateObjects = allDateObjects; } else { // No timeseries data available - use all nulls (no mock data) debugLog('No timeseries data available - chart will show empty with null values', 'warn'); // Still fetch historical data for Money Pages trend chart even without timeseries const propertyUrl = document.getElementById('propertyUrl')?.value || ''; if (propertyUrl) { try { // Calculate date range from saved audit or use default const dateRange = parseInt(document.getElementById('dateRange')?.value || '30', 10); const endDate = new Date().toISOString().split('T')[0]; const startDate = new Date(); startDate.setDate(startDate.getDate() - dateRange); const startDateStr = startDate.toISOString().split('T')[0]; debugLog(`Fetching historical data for Money Pages trend (no timeseries): ${startDateStr} to ${endDate}`, 'info'); const moneyPagesHistory = await fetchContentSchemaHistory(propertyUrl, startDateStr, endDate); // Phase 3: Render Money Pages trend chart if (typeof renderMoneyPagesTrendChart === 'function') { setTimeout(() => { renderMoneyPagesTrendChart(moneyPagesHistory); }, 1000); // Delay to ensure DOM is ready and chart container exists } else { debugLog('⚠ renderMoneyPagesTrendChart function not found', 'warn'); } } catch (error) { debugLog(`⚠ Error fetching historical data for Money Pages: ${error.message}`, 'warn'); // Still try to render with empty history (will show current audit data if available) if (typeof renderMoneyPagesTrendChart === 'function') { setTimeout(() => { renderMoneyPagesTrendChart([]); }, 1000); } } } // Use all null values - no mock/estimated data localEntityData = new Array(numDataPoints).fill(null); serviceAreaData = new Array(numDataPoints).fill(null); authorityData = new Array(numDataPoints).fill(null); visibilityData = new Array(numDataPoints).fill(null); contentSchemaData = new Array(numDataPoints).fill(null); contentSchemaDataEstimated = new Array(numDataPoints).fill(null); brandOverlayData = new Array(numDataPoints).fill(null); // Generate dates for the date range const fallbackDates = []; const fallbackDateObjects = []; for (let i = 0; i < numDataPoints; i++) { const dateObj = new Date(); if (dateRange <= 90) { // Daily data dateObj.setDate(dateObj.getDate() - (numDataPoints - 1 - i)); } else { // Weekly data dateObj.setDate(dateObj.getDate() - ((numDataPoints - 1 - i) * 7)); } fallbackDateObjects.push(dateObj); fallbackDates.push(dateObj.toLocaleDateString('en-GB', dateFormat)); } chartDates = fallbackDates; chartDateObjects = fallbackDateObjects; } // Calculate min and max across all datasets (filter out null values) const allData = [...localEntityData, ...serviceAreaData, ...authorityData, ...visibilityData, ...contentSchemaData, ...brandOverlayData].filter(v => v !== null && v !== undefined && !isNaN(v)); const dataMin = allData.length > 0 ? Math.min(...allData) : 0; const dataMax = allData.length > 0 ? Math.max(...allData) : 100; const dataRange = dataMax - dataMin; // Debug: Log Content/Schema data to see what we have const contentSchemaValid = contentSchemaData.filter(v => v !== null && v !== undefined && !isNaN(v)); debugLog(`Content/Schema dataset: ${contentSchemaValid.length} valid values out of ${contentSchemaData.length} total. Values: ${contentSchemaValid.join(', ')}`, 'info'); debugLog(`Content/Schema data array length: ${contentSchemaData.length}, chart dates length: ${chartDates.length}`, 'info'); debugLog(`Last few Content/Schema values: ${contentSchemaData.slice(-5).join(', ')}`, 'info'); debugLog(`Content/Schema map size: ${contentSchemaMap.size}, map entries: ${Array.from(contentSchemaMap.entries()).slice(-10).map(([d, s]) => `${d}=${s}`).join(', ')}`, 'info'); // Debug: Log Local Entity and Service Area data arrays const localEntityValid = localEntityData.filter(v => v !== null && v !== undefined && !isNaN(v)); const serviceAreaValid = serviceAreaData.filter(v => v !== null && v !== undefined && !isNaN(v)); debugLog(`Local Entity dataset: ${localEntityValid.length} valid values out of ${localEntityData.length} total. Last 5 values: ${localEntityData.slice(-5).join(', ')}`, 'info'); debugLog(`Service Area dataset: ${serviceAreaValid.length} valid values out of ${serviceAreaData.length} total. Last 5 values: ${serviceAreaData.slice(-5).join(', ')}`, 'info'); debugLog(`Local Entity map size: ${localEntityMap.size}, Service Area map size: ${serviceAreaMap.size}`, 'info'); // If Content/Schema has no valid data, log a warning if (contentSchemaValid.length === 0) { debugLog(`⚠ WARNING: Content/Schema dataset has NO valid values! Map has ${contentSchemaMap.size} entries.`, 'error'); debugLog(`Content/Schema history fetched: ${contentSchemaHistory.length} records`, 'info'); } // Calculate dynamic Y-axis range // Minimum range of 30, or actual range + padding if larger const minRange = 30; const padding = 5; // Add 5 points padding above and below const actualRange = Math.max(minRange, dataRange + (padding * 2)); // Calculate Y-axis min and max let yAxisMin = Math.max(0, Math.floor(dataMin - padding)); // Ensure the red risk band (30-39) is always visible in the chart area // by never allowing the bottom of the axis to sit above 30. if (yAxisMin > 30) { yAxisMin = 30; } const yAxisMax = Math.min(100, Math.ceil(yAxisMin + actualRange)); // Adjust step size based on range (smaller steps for smaller ranges) let stepSize = 10; if (actualRange <= 40) { stepSize = 5; } else if (actualRange <= 60) { stepSize = 10; } else { stepSize = 20; } debugLog(`Y-axis range: ${yAxisMin} to ${yAxisMax} (range: ${actualRange}, step: ${stepSize})`, 'info'); // Calculate trend percentages for each dataset // Filter out null values to get actual first and last data points const calculateTrend = (data) => { // Filter out null/undefined values const validData = data.filter(v => v !== null && v !== undefined && !isNaN(v)); if (validData.length < 2) return { percent: 0, isUp: false, isNeutral: true }; const first = validData[0]; const last = validData[validData.length - 1]; // Handle division by zero (when first value is 0) if (first === 0) { // If first is 0 and last is also 0, no change if (last === 0) { return { percent: 0, isUp: false, isNeutral: true }; } // If first is 0 but last has value, show as 100% increase return { percent: 100, isUp: true, isNeutral: false }; } const percent = ((last - first) / first) * 100; // Handle NaN or infinite result if (isNaN(percent) || !isFinite(percent)) { return { percent: 0, isUp: false, isNeutral: true }; } // Cap at 100% to avoid showing unrealistic percentages const cappedPercent = Math.min(Math.abs(percent), 100); // Consider values very close to 0 as neutral (within 0.1% threshold) const isNeutral = Math.abs(percent) < 0.1; return { percent: cappedPercent, isUp: percent > 0, isNeutral: isNeutral }; }; const localEntityTrend = calculateTrend(localEntityData); const serviceAreaTrend = calculateTrend(serviceAreaData); const authorityTrend = calculateTrend(authorityData); const visibilityTrend = calculateTrend(visibilityData); const contentSchemaTrend = calculateTrend(contentSchemaData); const brandOverlayTrend = calculateTrend(brandOverlayData); // Update HTML trend summary row const trendSummaryRow = document.getElementById('trendSummaryRow'); if (trendSummaryRow) { const buildPill = (label, trend) => { let arrow, sign, cls; if (trend.isNeutral) { arrow = '→'; sign = ''; cls = 'neutral'; } else { arrow = trend.isUp ? '↑' : '↓'; sign = trend.isUp ? '+' : ''; cls = trend.isUp ? 'up' : 'down'; } return `
    ${label} ${arrow} ${sign}${trend.percent.toFixed(1)}%
    `; }; trendSummaryRow.innerHTML = [ buildPill('Local Entity', localEntityTrend), buildPill('Service Area', serviceAreaTrend), buildPill('Authority', authorityTrend), buildPill('Visibility', visibilityTrend), buildPill('Content/Schema', contentSchemaTrend), buildPill('Brand & Entity', brandOverlayTrend) ].join(''); // Add note explaining trend calculation const trendNote = trendSummaryRow.nextElementSibling; if (!trendNote || !trendNote.classList.contains('trend-calculation-note')) { const noteDiv = document.createElement('div'); noteDiv.className = 'trend-calculation-note'; noteDiv.style.cssText = 'font-size: 0.75rem; color: #64748b; margin-top: 0.5rem; font-style: italic;'; noteDiv.textContent = 'Percentage changes are calculated from the first date to the last date in the selected range.'; trendSummaryRow.parentElement.insertBefore(noteDiv, trendSummaryRow.nextSibling); } } // Plugin to make year transition labels bold const yearLabelBoldPlugin = { id: 'yearLabelBold', afterDraw: (chart) => { const xScale = chart.scales.x; const ctx = chart.ctx; const rotation = -45 * (Math.PI / 180); // -45 degrees in radians // Get the chart dates array from the chart's data labels (original labels before callback) const chartDates = chart.data.labels || []; // Iterate through all data points to find year labels chartDates.forEach((originalLabel, dataIndex) => { // Check if original label contains a year (4-digit number at the end) if (originalLabel && /\d{4}$/.test(originalLabel)) { const label = originalLabel; // Get pixel position for this data point const tickPosition = xScale.getPixelForValue(dataIndex); const yPosition = chart.chartArea.bottom + 25; // Save context ctx.save(); // Translate to tick position and rotate ctx.translate(tickPosition, yPosition); ctx.rotate(rotation); // Set bold font with larger size ctx.font = 'bold 14px Arial'; ctx.fillStyle = '#000'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // Measure text to clear area properly const metrics = ctx.measureText(label); const textWidth = metrics.width; const textHeight = 18; // Clear a larger area to remove the original label ctx.globalCompositeOperation = 'destination-out'; ctx.fillRect(-textWidth/2 - 6, -textHeight/2 - 3, textWidth + 12, textHeight + 6); // Switch back to normal drawing ctx.globalCompositeOperation = 'source-over'; // Draw bold label ctx.fillText(label, 0, 0); // Restore context ctx.restore(); } }); } }; // Plugin to add RAG background bands with stronger contrast const ragBackgroundPlugin = { id: 'ragBackground', beforeDraw: (chart) => { const ctx = chart.ctx; const chartArea = chart.chartArea; const yScale = chart.scales.y; // Red zone (0-39) - increased opacity and saturation if (yAxisMin <= 39) { const start = Math.max(chartArea.top, yScale.getPixelForValue(Math.max(0, yAxisMin))); const end = Math.min(chartArea.bottom, yScale.getPixelForValue(Math.min(39, yAxisMax))); ctx.fillStyle = 'rgba(220, 38, 38, 0.18)'; // More saturated red, higher opacity ctx.fillRect(chartArea.left, start, chartArea.right - chartArea.left, end - start); } // Amber zone (40-69) - increased opacity and saturation if (yAxisMin <= 69 && yAxisMax >= 40) { const start = Math.max(chartArea.top, yScale.getPixelForValue(Math.max(40, yAxisMin))); const end = Math.min(chartArea.bottom, yScale.getPixelForValue(Math.min(69, yAxisMax))); ctx.fillStyle = 'rgba(234, 179, 8, 0.18)'; // More saturated amber/yellow, higher opacity ctx.fillRect(chartArea.left, start, chartArea.right - chartArea.left, end - start); } // Green zone (70-100) - increased opacity and saturation if (yAxisMax >= 70) { const start = Math.max(chartArea.top, yScale.getPixelForValue(Math.max(70, yAxisMin))); const end = Math.min(chartArea.bottom, yScale.getPixelForValue(Math.min(100, yAxisMax))); ctx.fillStyle = 'rgba(22, 163, 74, 0.18)'; // More saturated green, higher opacity ctx.fillRect(chartArea.left, start, chartArea.right - chartArea.left, end - start); } } }; // Plugin to draw vertical dividing lines at period transitions (months and years) const periodDividingLinePlugin = { id: 'periodDividingLine', afterDraw: (chart) => { const xScale = chart.scales.x; const ctx = chart.ctx; const chartArea = chart.chartArea; // Detect period transitions (month and year changes) const periodTransitions = []; let lastMonth = null; let lastYear = null; // Use stored date objects to detect month/year changes chartDateObjects.forEach((dateObj, index) => { if (dateObj instanceof Date) { const currentMonth = dateObj.getMonth(); // 0-11 const currentYear = dateObj.getFullYear(); // Check for month or year transition if (lastMonth !== null && lastYear !== null) { if (currentYear !== lastYear || currentMonth !== lastMonth) { periodTransitions.push(index); } } lastMonth = currentMonth; lastYear = currentYear; } }); // Draw vertical dotted lines at period transitions periodTransitions.forEach((dataIndex) => { // Get pixel position for this data point const tickPosition = xScale.getPixelForValue(dataIndex); // Only draw if within chart area horizontally if (tickPosition >= chartArea.left && tickPosition <= chartArea.right) { ctx.save(); ctx.strokeStyle = 'rgba(120, 120, 120, 0.7)'; // Medium grey, more visible ctx.lineWidth = 3; // Thicker lines ctx.setLineDash([3, 4]); // Dotted line pattern ctx.beginPath(); // Extend line beyond chart area to reach axis labels const lineTop = chartArea.top - 10; // Extend above chart const lineBottom = chartArea.bottom + 50; // Extend below chart to reach axis labels ctx.moveTo(tickPosition, lineTop); ctx.lineTo(tickPosition, lineBottom); ctx.stroke(); ctx.restore(); } }); } }; // Debug: Log the actual data arrays being passed to the chart const brandOverlayValid = brandOverlayData.filter(v => v !== null && v !== undefined && !isNaN(v)); debugLog(`Chart creation: Local Entity data length=${localEntityData.length}, last 3 values=${localEntityData.slice(-3).join(', ')}, Service Area data length=${serviceAreaData.length}, last 3 values=${serviceAreaData.slice(-3).join(', ')}, Brand & Entity data length=${brandOverlayData.length}, valid values=${brandOverlayValid.length}, last 3 values=${brandOverlayData.slice(-3).join(', ')}, all values=${brandOverlayData.join(', ')}`, 'info'); // DEBUG: Log exact labels being passed to Chart.js debugLog(`[Trend Chart] Creating chart with ${chartDates.length} labels`, 'info'); debugLog(`[Trend Chart] First 5 labels: ${chartDates.slice(0, 5).join(', ')}`, 'info'); debugLog(`[Trend Chart] Last 5 labels: ${chartDates.slice(-5).join(', ')}`, 'info'); debugLog(`[Trend Chart] Latest audit date: ${latestAuditDateStr}`, 'info'); debugLog(`[Trend Chart] Data arrays length - Local Entity: ${localEntityData.length}, Service Area: ${serviceAreaData.length}, Authority: ${authorityData.length}, Visibility: ${visibilityData.length}, Content/Schema: ${contentSchemaData.length}`, 'info'); // Remove inline loading spinner if it exists const existingLoading = trendChartContainer.querySelector('.trend-chart-loading'); if (existingLoading) { existingLoading.remove(); debugLog('Removed inline trend chart loading spinner', 'info'); } // Hide full-screen loading overlay hideFullScreenLoading(); // Destroy existing chart if it exists (prevent "Canvas is already in use" error) if (window.trendChart) { try { window.trendChart.destroy(); debugLog('✓ Destroyed existing trend chart before creating new one', 'info'); } catch (e) { debugLog(`⚠ Error destroying existing trend chart: ${e.message}`, 'warn'); } window.trendChart = null; } // Also check if Chart.js has an instance registered for this canvas try { const existingChart = Chart.getChart(trendCtx); if (existingChart) { existingChart.destroy(); debugLog('✓ Destroyed Chart.js registered instance for trendChart canvas', 'info'); } } catch (e) { debugLog(`⚠ Error checking/destroying Chart.js instance: ${e.message}`, 'warn'); } window.trendChart = new Chart(trendCtx, { type: 'line', data: { labels: chartDates, datasets: [ { label: 'Local Entity', data: localEntityData, borderColor: 'rgba(147, 51, 234, 1)', // Purple backgroundColor: 'rgba(147, 51, 234, 0.1)', borderWidth: 3, tension: 0.4, pointRadius: 0, pointHoverRadius: 5, spanGaps: true // Connect across null values to show continuous line }, { label: 'Service Area', data: serviceAreaData, borderColor: '#00FFFF', // Cyan (not RAG color) backgroundColor: 'rgba(0, 255, 255, 0.1)', borderWidth: 3, tension: 0.4, pointRadius: 0, pointHoverRadius: 5, spanGaps: true // Connect across null values to show continuous line }, { label: 'Authority', data: authorityData, borderColor: '#99004C', // Dark pink/magenta backgroundColor: 'rgba(153, 0, 76, 0.1)', borderWidth: 3, tension: 0.4, pointRadius: 0, pointHoverRadius: 5 // No spanGaps - stop at last available GSC data (Dec 4) }, { label: 'Visibility', data: visibilityData, borderColor: 'rgba(37, 99, 235, 1)', // Blue backgroundColor: 'rgba(59, 130, 246, 0.1)', borderWidth: 3, tension: 0.4, pointRadius: 0, pointHoverRadius: 5 // No spanGaps - stop at last available GSC data (Dec 4) }, { label: 'Content / Schema', data: contentSchemaData, borderColor: 'rgba(107, 114, 128, 1)', // Grey backgroundColor: 'rgba(107, 114, 128, 0.1)', borderWidth: 3, tension: 0.4, pointRadius: 0, // Hide points - show as solid line pointHoverRadius: 5, spanGaps: true, // Connect across null values to show continuous line pointBackgroundColor: 'rgba(107, 114, 128, 1)', pointBorderColor: '#ffffff', pointBorderWidth: 2 }, { label: 'Brand & Entity', data: brandOverlayData.length === chartDates.length ? brandOverlayData : (() => { // Ensure data array matches labels length const adjusted = [...brandOverlayData]; while (adjusted.length < chartDates.length) { adjusted.push(null); } return adjusted.slice(0, chartDates.length); })(), borderColor: '#FFFF66', // Bright yellow backgroundColor: 'rgba(255, 255, 102, 0.1)', // Bright yellow with transparency borderWidth: 3, // Increased from 2 to make more visible borderDash: [5, 5], // Dashed line to indicate overlay tension: 0.4, pointRadius: 3, // Show points to make line more visible pointHoverRadius: 6, pointBackgroundColor: '#FFFF66', pointBorderColor: '#FFFF66', spanGaps: true, hidden: false // Explicitly ensure dataset is visible } ] }, plugins: [ragBackgroundPlugin, yearLabelBoldPlugin, periodDividingLinePlugin], options: { responsive: true, maintainAspectRatio: false, animation: false, // Disable animation for better performance when changing periods layout: { padding: { bottom: 100, // Extra padding for rotated 45-degree X-axis labels (need space for diagonal text) left: 15, // Space for left Y-axis labels right: 50, // Extra space for right Y-axis labels (100, 80, 60, 40, 29) top: 10 } }, scales: { x: { ticks: { maxRotation: 45, minRotation: 45, font: { size: 11, weight: 'normal' }, callback: function(value, index) { // Only show non-empty labels const label = this.getLabelForValue(value); // Hide year labels (they'll be drawn by plugin in bold) if (label && /\d{4}$/.test(label)) { return ''; // Return empty to hide, plugin will draw it } return label || ''; } }, grid: { display: true, color: 'rgba(0, 0, 0, 0.05)' } }, y: { min: yAxisMin, max: yAxisMax, position: 'left', ticks: { stepSize: stepSize, font: { size: 12, weight: 'bold' } }, grid: { color: 'rgba(0, 0, 0, 0.1)', drawBorder: true } }, y1: { min: yAxisMin, max: yAxisMax, position: 'right', ticks: { stepSize: stepSize, font: { size: 12, weight: 'bold' } }, grid: { display: false, drawBorder: false } } }, plugins: { legend: { display: true, position: 'top', labels: { font: { size: 16, weight: 'bold' }, padding: 25, usePointStyle: false, boxWidth: 60, boxHeight: 4, // Use default legend labels (pillar names only, no trends) generateLabels: function(chart) { const original = Chart.defaults.plugins.legend.labels.generateLabels; return original.call(this, chart); } } }, tooltip: { mode: 'index', intersect: false } } } }); debugLog('✓ Trend chart created successfully', 'success'); debugLog(`trendChart type after creation: ${typeof window.trendChart}`, 'info'); debugLog(`trendChart instanceof Chart: ${window.trendChart instanceof Chart}`, 'info'); debugLog(`Chart data points: Local Entity=${localEntityData.length}, Visibility=${visibilityData.length}, Authority=${authorityData.length}`, 'info'); // Add event listeners for Authority mode toggle buttons (sync with KPI toggle) ['all', 'nonEducation', 'money'].forEach(mode => { const btn = document.getElementById(`trend-mode-${mode}`); if (btn) { btn.addEventListener('click', () => { // Update selected mode (shared with KPI toggle) window.trendAuthorityMode = mode; debugLog(`📊 Trend chart Authority mode changed to: ${mode === 'all' ? 'All pages' : mode === 'nonEducation' ? 'Exclude education' : 'Money pages only'}`, 'info'); // Update button styles for both KPI and trend toggles ['all', 'nonEducation', 'money'].forEach(m => { // Update trend toggle buttons const trendBtn = document.getElementById(`trend-mode-${m}`); if (trendBtn) { if (m === mode) { trendBtn.style.background = '#10b981'; trendBtn.style.color = 'white'; } else { trendBtn.style.background = 'white'; trendBtn.style.color = '#666'; } } // Update KPI toggle buttons const kpiBtn = document.getElementById(`kpi-mode-${m}`); if (kpiBtn) { if (m === mode) { kpiBtn.style.background = '#10b981'; kpiBtn.style.color = 'white'; } else { kpiBtn.style.background = 'white'; kpiBtn.style.color = '#666'; } } }); // Redraw the chart with new Authority data if (window.trendChart && typeof displayDashboard === 'function') { // Re-run displayDashboard to recalculate with new mode displayDashboard(); } }); } }); debugLog('=== DISPLAY DASHBOARD: Complete ===', 'success'); } catch (e) { debugLog(`✗ Error creating trend chart: ${e.message}`, 'error'); debugLog(`Stack: ${e.stack}`, 'error'); console.error('Error creating trend chart:', e); // Hide full-screen loading overlay hideFullScreenLoading(); // Show error message to user const trendCanvas = document.getElementById('trendChart'); if (trendCanvas && trendCanvas.parentElement) { // Remove any existing error messages const existingError = trendCanvas.parentElement.querySelector('.trend-chart-error'); if (existingError) existingError.remove(); // Show error message to user const errorDiv = document.createElement('div'); errorDiv.className = 'trend-chart-error'; errorDiv.style.cssText = 'background: #fee2e2; padding: 1rem; border-radius: 4px; border-left: 3px solid #ef4444; margin-bottom: 1rem; font-size: 0.9rem; color: #991b1b;'; errorDiv.innerHTML = `Error loading trend chart: ${e.message}. Please refresh the page or run a new audit.`; trendCanvas.parentElement.insertBefore(errorDiv, trendCanvas); } } }, 100); } // Function to load shared audit data async function loadSharedAudit(shareId) { try { debugLog(`Loading shared audit: ${shareId}`, 'info'); const response = await fetch(apiUrl(`/api/supabase/get-shared-audit?shareId=${encodeURIComponent(shareId)}`)); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Failed to load shared audit'); } const result = await response.json(); if (result.status === 'ok' && result.data) { debugLog('✓ Shared audit loaded successfully', 'success'); return result.data; } else { throw new Error('Invalid shared audit data'); } } catch (error) { debugLog(`✗ Error loading shared audit: ${error.message}`, 'error'); showStatus(`Failed to load shared audit: ${error.message}`, 'error'); return null; } } // Function to create shareable link (exposed globally for onclick) window.createShareableLink = async function() { const savedAudit = await loadAuditResults(); if (!savedAudit || !savedAudit.scores) { showStatus('No audit data available to share. Please run an audit first.', 'error'); return; } try { showStatus('Creating shareable link...', 'info'); const response = await fetch(apiUrl('/api/supabase/create-shared-audit'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ auditData: savedAudit }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Failed to create shareable link'); } const result = await response.json(); if (result.status === 'ok' && result.shareUrl) { // Copy to clipboard await navigator.clipboard.writeText(result.shareUrl); showStatus(`Shareable link created and copied to clipboard! Link expires in 30 days.`, 'success'); // Show the link in an alert or modal alert(`Shareable link created!\n\n${result.shareUrl}\n\n(Link copied to clipboard)\n\nThis link expires in 30 days.`); } else { throw new Error('Invalid response from server'); } } catch (error) { debugLog(`✗ Error creating shareable link: ${error.message}`, 'error'); showStatus(`Failed to create shareable link: ${error.message}`, 'error'); } } // Initialize on load window.addEventListener('DOMContentLoaded', () => { debugLog('=== PAGE LOAD: DOMContentLoaded event fired ===', 'info'); debugLog(`Window location: ${window.location.href}`, 'info'); debugLog(`User agent: ${navigator.userAgent}`, 'info'); loadConfig(); // Check for share parameter in URL const urlParams = new URLSearchParams(window.location.search); const shareId = urlParams.get('share'); // Load and display last audit results if available // Wait a tick to ensure loadConfig() has finished updating the date range input setTimeout(async () => { let auditToDisplay = null; if (shareId) { // Load shared audit debugLog(`Share ID detected: ${shareId}`, 'info'); auditToDisplay = await loadSharedAudit(shareId); if (auditToDisplay) { // Save shared audit to localStorage temporarily for display safeSetLocalStorage('last_audit_results', auditToDisplay); // Show a banner indicating this is a shared view const banner = document.createElement('div'); banner.style.cssText = 'background: #dbeafe; padding: 1rem; border-radius: 4px; border-left: 4px solid #3b82f6; margin-bottom: 1rem; font-size: 0.9rem; color: #1e40af;'; banner.innerHTML = '📤 Shared Audit View - This is a shared audit. You can view all results but cannot run new audits from this view.'; const dashboard = document.getElementById('dashboard'); if (dashboard) { dashboard.insertBefore(banner, dashboard.firstChild); } // Hide the run audit button const runAuditBtn = document.getElementById('runAudit'); if (runAuditBtn) { runAuditBtn.style.display = 'none'; } } } // If no shared audit or failed to load, try localStorage or Supabase if (!auditToDisplay) { auditToDisplay = await loadAuditResults(); } // If still no audit data, try fetching from Supabase if (!auditToDisplay) { const propertyUrl = localStorage.getItem('gsc_property_url'); if (propertyUrl) { debugLog('No audit data in localStorage, fetching from Supabase...', 'info'); auditToDisplay = await fetchLatestAuditFromSupabase(propertyUrl); if (auditToDisplay) { // Save to localStorage for future use safeSetLocalStorage('last_audit_results', auditToDisplay); // Update timestamp display if (auditToDisplay.timestamp) { updateAuditTimestamp(auditToDisplay.timestamp); } debugLog('✓ Latest audit loaded from Supabase and saved to localStorage', 'success'); } else { debugLog('⚠ No audit data found in Supabase either', 'warn'); } } else { debugLog('⚠ Cannot fetch from Supabase: property URL not configured', 'warn'); } } const savedAudit = auditToDisplay; if (savedAudit && savedAudit.scores && savedAudit.searchData) { // CRITICAL: Restore Money Pages Priority data FIRST before any rendering debugLog(`Checking saved audit for moneyPagePriorityData: ${!!savedAudit.moneyPagePriorityData}, type: ${Array.isArray(savedAudit.moneyPagePriorityData) ? 'array' : typeof savedAudit.moneyPagePriorityData}, length: ${Array.isArray(savedAudit.moneyPagePriorityData) ? savedAudit.moneyPagePriorityData.length : 'N/A'}, value: ${JSON.stringify(savedAudit.moneyPagePriorityData ? (Array.isArray(savedAudit.moneyPagePriorityData) ? `[${savedAudit.moneyPagePriorityData.length} items]` : savedAudit.moneyPagePriorityData) : 'null').substring(0, 100)}`, 'info'); // Check if moneyPagePriorityData exists but is null/empty if (savedAudit.moneyPagePriorityData !== null && savedAudit.moneyPagePriorityData !== undefined) { if (Array.isArray(savedAudit.moneyPagePriorityData) && savedAudit.moneyPagePriorityData.length > 0) { window.moneyPagePriorityData = savedAudit.moneyPagePriorityData; debugLog(`✓ Restored moneyPagePriorityData from saved audit: ${savedAudit.moneyPagePriorityData.length} pages`, 'success'); } else if (Array.isArray(savedAudit.moneyPagePriorityData) && savedAudit.moneyPagePriorityData.length === 0) { debugLog(`⚠ moneyPagePriorityData exists but is empty array`, 'warn'); window.moneyPagePriorityData = []; } else { debugLog(`⚠ moneyPagePriorityData exists but is not an array: ${typeof savedAudit.moneyPagePriorityData}`, 'warn'); window.moneyPagePriorityData = []; } } else { debugLog(`⚠ moneyPagePriorityData is null/undefined in saved audit. Keys in savedAudit: ${Object.keys(savedAudit).join(', ')}`, 'warn'); // Try to rebuild if we have moneyPagesMetrics if (savedAudit.scores?.moneyPagesMetrics?.rows && savedAudit.scores.moneyPagesMetrics.rows.length > 0) { debugLog(`⚠ Will try to rebuild moneyPagePriorityData from moneyPagesMetrics (${savedAudit.scores.moneyPagesMetrics.rows.length} rows)`, 'info'); } } if (savedAudit.moneySegmentMetrics) { window.moneySegmentMetrics = savedAudit.moneySegmentMetrics; debugLog(`✓ Restored moneySegmentMetrics from saved audit`, 'success'); } else { debugLog(`⚠ moneySegmentMetrics NOT found in saved audit`, 'warn'); } // Ensure moneyPagesMetrics is in scores if it exists in the audit data if (!savedAudit.scores.moneyPagesMetrics && savedAudit.moneyPagesMetrics) { savedAudit.scores.moneyPagesMetrics = savedAudit.moneyPagesMetrics; debugLog('✓ Moved moneyPagesMetrics from audit root to scores', 'success'); } // Store globally for Money Pages sections if (savedAudit.scores.moneyPagesMetrics) { window.currentMoneyPagesMetrics = savedAudit.scores.moneyPagesMetrics; window.moneyPagesMetrics = savedAudit.scores.moneyPagesMetrics; debugLog(`✓ Stored moneyPagesMetrics globally: ${savedAudit.scores.moneyPagesMetrics.rows?.length || 0} rows`, 'success'); } // If saved audit has a date range, use it to set the input field and button // This ensures the UI matches the saved audit's date range if (savedAudit.dateRange) { const savedDateRange = savedAudit.dateRange; document.getElementById('dateRange').value = savedDateRange; // Update active button to match saved date range document.querySelectorAll('.date-range-btn').forEach(btn => { btn.classList.remove('active'); const btnDays = parseInt(btn.getAttribute('data-days')); if (btnDays === savedDateRange) { btn.classList.add('active'); } }); // Update localStorage to match localStorage.setItem('gsc_date_range', savedDateRange); debugLog(`Set date range to ${savedDateRange} to match saved audit`, 'info'); } // Now check if they match (they should, since we just set it) const currentDateRange = parseInt(document.getElementById('dateRange')?.value) || 30; const savedDateRange = savedAudit.dateRange || currentDateRange; debugLog(`Checking date range match: saved=${savedDateRange}, current=${currentDateRange}`, 'info'); if (savedDateRange === currentDateRange) { debugLog('Loading last audit results from localStorage...', 'info'); // CRITICAL: If moneyPagePriorityData is null but we have moneyPagesMetrics, try to rebuild it // But only if buildMoneyPageMetrics is available if ((!window.moneyPagePriorityData || window.moneyPagePriorityData.length === 0) && savedAudit.scores?.moneyPagesMetrics?.rows && savedAudit.scores.moneyPagesMetrics.rows.length > 0) { debugLog(`Attempting to rebuild moneyPagePriorityData from moneyPagesMetrics (${savedAudit.scores.moneyPagesMetrics.rows.length} rows)...`, 'info'); // Wait for buildMoneyPageMetrics to be available (with timeout) let attempts = 0; const maxAttempts = 20; // 2 seconds total wait debugLog(`Waiting for buildMoneyPageMetrics... (currently: ${typeof window.buildMoneyPageMetrics})`, 'info'); while (typeof window.buildMoneyPageMetrics !== 'function' && attempts < maxAttempts) { await new Promise(resolve => setTimeout(resolve, 100)); attempts++; if (attempts % 5 === 0) { debugLog(`Still waiting for buildMoneyPageMetrics... (attempt ${attempts}/${maxAttempts})`, 'info'); } } debugLog(`buildMoneyPageMetrics check after wait: ${typeof window.buildMoneyPageMetrics}`, 'info'); if (typeof window.buildMoneyPageMetrics === 'function') { try { const topPagesForPriority = savedAudit.scores.moneyPagesMetrics.rows.map(row => ({ page: row.url, url: row.url, clicks: row.clicks || 0, impressions: row.impressions || 0, // row.ctr is already a ratio (0-1). Do not multiply by 100. ctr: (row.ctr || 0), position: row.avgPosition || 0, avgPosition: row.avgPosition || 0, title: row.title || row.url })); window.moneyPagePriorityData = window.buildMoneyPageMetrics(topPagesForPriority, savedAudit.schemaAudit || null); debugLog(`✓ Rebuilt moneyPagePriorityData: ${window.moneyPagePriorityData.length} pages`, 'success'); // Save rebuilt data back to localStorage savedAudit.moneyPagePriorityData = window.moneyPagePriorityData; safeSetLocalStorage('last_audit_results', savedAudit); } catch (error) { debugLog(`⚠ Failed to rebuild moneyPagePriorityData: ${error.message}`, 'warn'); } } else { debugLog(`⚠ buildMoneyPageMetrics still not available after ${maxAttempts * 100}ms wait`, 'warn'); } } // Show dashboard immediately with saved results document.getElementById('dashboard').style.display = 'block'; document.getElementById('loading').classList.remove('show'); // Ensure searchData has timeseries if available const searchDataWithTimeseries = savedAudit.searchData || {}; if (!searchDataWithTimeseries.timeseries) { // Try to load timeseries from saved audit if (savedAudit.timeseries) { searchDataWithTimeseries.timeseries = savedAudit.timeseries; debugLog(`✓ Restored timeseries data from saved audit: ${savedAudit.timeseries.length} data points`, 'success'); } else { // Try to fetch timeseries from Supabase gsc_timeseries table debugLog('Fetching timeseries data from Supabase for Score Trends chart...', 'info'); try { const propertyUrl = savedAudit.searchData?.propertyUrl || localStorage.getItem('gsc_property_url'); if (propertyUrl) { const endDate = new Date().toISOString().split('T')[0]; const startDate = new Date(); startDate.setDate(startDate.getDate() - 30); // Last 30 days const startDateStr = startDate.toISOString().split('T')[0]; const timeseriesResponse = await fetch(apiUrl(`/api/supabase/get-audit-history?propertyUrl=${encodeURIComponent(propertyUrl)}&startDate=${startDateStr}&endDate=${endDate}`)); if (timeseriesResponse.ok) { const timeseriesData = await timeseriesResponse.json(); if (timeseriesData.status === 'ok' && timeseriesData.timeseries && Array.isArray(timeseriesData.timeseries)) { searchDataWithTimeseries.timeseries = timeseriesData.timeseries; debugLog(`✓ Loaded timeseries data from Supabase: ${timeseriesData.timeseries.length} data points`, 'success'); } } } } catch (error) { debugLog(`⚠ Failed to load timeseries data: ${error.message}`, 'warn'); } } } displayDashboard( savedAudit.scores, searchDataWithTimeseries, savedAudit.snippetReadiness || 0, savedAudit.schemaAudit || null, savedAudit.localSignals || null ); debugLog('✓ Last audit results displayed', 'success'); } else { debugLog(`Saved audit date range (${savedDateRange}) doesn't match current (${currentDateRange}). Dashboard not displayed.`, 'warn'); // Don't show dashboard - user needs to run new audit } } else { // No audit data found - show empty state message debugLog('⚠ No audit data found. Please run a new audit.', 'warn'); const dashboard = document.getElementById('dashboard'); if (dashboard) { dashboard.style.display = 'block'; dashboard.innerHTML = `

    No Audit Data Found

    Run your first audit to see the dashboard content.

    `; } document.getElementById('loading').classList.remove('show'); } }, 100); // Increased delay to ensure loadConfig completes debugLog('=== PAGE LOAD: Initialization complete ===', 'success'); }); // PDF Report Generation async function generatePDFReport() { console.log('[PDF] ===== PDF GENERATION STARTED ====='); const btn = document.getElementById('generatePdfBtn'); const statusDiv = document.getElementById('pdfStatus'); if (!btn) { console.error('[PDF] Generate PDF button not found!'); alert('Error: Generate PDF button not found. Please refresh the page.'); return; } if (!statusDiv) { console.error('[PDF] PDF status div not found!'); } // Check if html2pdf is available if (typeof html2pdf === 'undefined') { const errorMsg = '❌ PDF library not loaded. Please refresh the page.'; if (statusDiv) { statusDiv.textContent = errorMsg; statusDiv.style.color = '#dc2626'; } console.error('[PDF] html2pdf is not defined'); alert(errorMsg); return; } console.log('[PDF] html2pdf library is available'); // Check if dashboard has data const dashboard = document.getElementById('dashboard'); if (!dashboard || dashboard.style.display === 'none') { const errorMsg = '⚠️ Please run an audit first to generate a report.'; if (statusDiv) { statusDiv.textContent = errorMsg; statusDiv.style.color = '#dc2626'; } console.warn('[PDF] Dashboard not available or hidden'); return; } console.log('[PDF] Dashboard is available and visible'); // Disable button and show status btn.disabled = true; btn.style.opacity = '0.6'; if (statusDiv) { statusDiv.textContent = '⏳ Generating PDF report...'; statusDiv.style.color = '#2563eb'; } console.log('[PDF] Starting PDF generation process...'); try { // Load saved audit data const savedAudit = loadAuditResultsSync(); if (!savedAudit || !savedAudit.scores) { throw new Error('No audit data available. Please run an audit first.'); } // Get property URL and date range const propertyUrl = document.getElementById('propertyUrl')?.value || 'N/A'; const dateRange = document.getElementById('dateRange')?.value || 30; const auditDate = savedAudit.auditDate || new Date().toISOString().split('T')[0]; // Convert charts to images first (before creating HTML) console.log('[PDF] Converting charts to images...'); // Create temporary canvas elements to capture charts const radarCanvas = document.getElementById('radarChart'); const trendCanvas = document.getElementById('trendChart'); const snippetCanvas = document.getElementById('snippetReadinessPieChart'); let radarImgData = ''; let trendImgData = ''; let snippetImgData = ''; if (radarCanvas && window.radarChart) { radarImgData = radarCanvas.toDataURL('image/png'); console.log('[PDF] Radar chart converted to image'); } if (trendCanvas && window.trendChart) { trendImgData = trendCanvas.toDataURL('image/png'); console.log('[PDF] Trend chart converted to image'); } if (snippetCanvas && window.snippetReadinessChart) { snippetImgData = snippetCanvas.toDataURL('image/png'); console.log('[PDF] Snippet readiness chart converted to image'); } // Create report HTML with embedded chart images console.log('[PDF] Creating report HTML...'); const reportHTML = createReportHTML(savedAudit, propertyUrl, dateRange, auditDate, { radarChart: radarImgData, trendChart: trendImgData, snippetReadinessChart: snippetImgData }); // Verify reportHTML has content if (!reportHTML || reportHTML.length < 100) { console.error('[PDF] Report HTML is empty or too short:', reportHTML ? reportHTML.length : 'null/undefined'); throw new Error('Report HTML is empty or too short. Cannot generate PDF.'); } console.log('[PDF] Report HTML created successfully'); console.log('[PDF] Report HTML length:', reportHTML.length); console.log('[PDF] Report HTML starts with:', reportHTML.substring(0, 100)); console.log('[PDF] Report HTML ends with:', reportHTML.substring(reportHTML.length - 100)); // Check if reportHTML contains expected content if (!reportHTML.includes('GAIO Audit Report')) { console.warn('[PDF] Warning: Report HTML may not contain expected content'); } // Use Workshop Planner approach: exactly match printJourney pattern from print-export-dialog.tsx const printWindow = window.open('', '_blank'); if (!printWindow) { throw new Error('Could not open print window. Please allow popups for this site.'); } console.log('[PDF] Print window opened'); console.log('[PDF] Writing HTML content (length:', reportHTML.length, ')'); // Write the complete report HTML directly (it already includes full HTML structure) printWindow.document.write(reportHTML); printWindow.document.close(); console.log('[PDF] HTML written and document closed'); // Add a slight delay before triggering print to ensure content is fully loaded (exactly like printJourney) setTimeout(function() { try { console.log('[PDF] Attempting to print styled report...'); printWindow.focus(); printWindow.print(); console.log('[PDF] Print dialog triggered'); if (statusDiv) { statusDiv.textContent = '✅ Print dialog opened. Save as PDF from the print dialog.'; statusDiv.style.color = '#10b981'; setTimeout(() => { statusDiv.textContent = ''; }, 5000); } } catch (error) { console.error('[PDF] Print error:', error); if (statusDiv) { statusDiv.textContent = '❌ Error opening print dialog. Please try again.'; statusDiv.style.color = '#dc2626'; } alert('Failed to open print dialog: ' + error.message); } }, 1500); // Increased timeout to ensure content is fully loaded (matches printJourney) } catch (error) { console.error('[PDF] ===== PDF GENERATION ERROR ====='); console.error('[PDF] Error message:', error.message); console.error('[PDF] Error stack:', error.stack); console.error('[PDF] Error object:', error); const errorMsg = `❌ Error: ${error.message}`; if (statusDiv) { statusDiv.textContent = errorMsg; statusDiv.style.color = '#dc2626'; } // Also show alert so user definitely sees the error alert(`PDF Generation Failed:\n\n${error.message}\n\nCheck the browser console for more details.`); } finally { btn.disabled = false; btn.style.opacity = '1'; console.log('[PDF] ===== PDF GENERATION COMPLETE ====='); } } // Convert Chart.js charts to images async function convertChartsToImages(container) { console.log('[PDF] Converting charts to images...'); // Convert radar chart const radarCanvas = document.getElementById('radarChart'); if (radarCanvas && window.radarChart) { console.log('[PDF] Converting radar chart...'); const radarImg = radarCanvas.toDataURL('image/png'); const radarImgElement = container.querySelector('#radarChartImg'); if (radarImgElement) { radarImgElement.src = radarImg; radarImgElement.style.display = 'block'; console.log('[PDF] Radar chart image set, data URL length:', radarImg.length); } else { console.warn('[PDF] Radar chart image element not found in container'); } } else { console.warn('[PDF] Radar chart canvas or chart instance not found'); } // Convert trend chart const trendCanvas = document.getElementById('trendChart'); if (trendCanvas && window.trendChart) { console.log('[PDF] Converting trend chart...'); const trendImg = trendCanvas.toDataURL('image/png'); const trendImgElement = container.querySelector('#trendChartImg'); if (trendImgElement) { trendImgElement.src = trendImg; trendImgElement.style.display = 'block'; console.log('[PDF] Trend chart image set, data URL length:', trendImg.length); } else { console.warn('[PDF] Trend chart image element not found in container'); } } else { console.warn('[PDF] Trend chart canvas or chart instance not found'); } // Convert snippet readiness chart const snippetCanvas = document.getElementById('snippetReadinessPieChart'); if (snippetCanvas && window.snippetReadinessChart) { console.log('[PDF] Converting snippet readiness chart...'); const snippetImg = snippetCanvas.toDataURL('image/png'); const snippetImgElement = container.querySelector('#snippetReadinessChartImg'); if (snippetImgElement) { snippetImgElement.src = snippetImg; snippetImgElement.style.display = 'block'; console.log('[PDF] Snippet readiness chart image set, data URL length:', snippetImg.length); } else { console.warn('[PDF] Snippet readiness chart image element not found in container'); } } else { console.warn('[PDF] Snippet readiness chart canvas or chart instance not found'); } console.log('[PDF] Chart conversion complete'); } // Create report HTML content function createReportHTML(auditData, propertyUrl, dateRange, auditDate, chartImages = {}) { const scores = auditData.scores || {}; const searchData = auditData.searchData || {}; const schemaAudit = auditData.schemaAudit || {}; const snippetReadiness = auditData.snippetReadiness || 0; const localSignals = auditData.localSignals || null; const hasLocalSignals = localSignals && localSignals.status === 'ok' && localSignals.data; const localSignalsData = hasLocalSignals ? localSignals.data : null; // Helper function for RAG status const getRAGStatus = (score) => { if (score >= 70) return { color: '#10b981', label: 'Green', text: 'Good' }; if (score >= 40) return { color: '#f59e0b', label: 'Amber', text: 'Needs Improvement' }; return { color: '#ef4444', label: 'Red', text: 'Poor' }; }; // Get next steps (create a helper function similar to the one in displayDashboard) const getNextStepsForPDF = (scores, searchData, schemaAudit) => { const nextSteps = {}; Object.entries(scores).forEach(([key, score]) => { const steps = []; switch(key) { case 'contentSchema': if (schemaAudit && schemaAudit.data) { const schemaData = schemaAudit.data; const { coverage, schemaTypes, richEligible } = schemaData; // Use allDetectedTypes if available (all types), otherwise use schemaTypes (all types, sorted by count) const allTypes = new Set(); if (schemaData.allDetectedTypes && Array.isArray(schemaData.allDetectedTypes)) { // Use all detected types for accurate calculation schemaData.allDetectedTypes.forEach(type => { if (type) allTypes.add(type); }); } else if (schemaTypes && Array.isArray(schemaTypes)) { // Fallback: collect from schemaTypes array (contains all types, sorted by count) schemaTypes.forEach(item => { if (item.type) allTypes.add(item.type); }); } const foundationTypes = ['Organization', 'Person', 'WebSite', 'BreadcrumbList']; const foundationPresent = foundationTypes.filter(type => allTypes.has(type)).length; const foundationMissing = foundationTypes.filter(type => !allTypes.has(type)); if (foundationPresent < 4) { steps.push(`Foundation schemas (30%): ${foundationPresent}/4 present. Add: ${foundationMissing.join(', ')}`); } else { steps.push(`Foundation schemas (30%): All 4 present`); } const richEligibleCount = Object.values(richEligible || {}).filter(eligible => eligible === true).length; const richResultTypesCount = ['Article', 'Event', 'FAQPage', 'Product', 'LocalBusiness', 'Course', 'Review', 'HowTo', 'VideoObject', 'ImageObject', 'ItemList'].length; if (richEligibleCount < richResultTypesCount) { steps.push(`Rich results (35%): ${richEligibleCount}/${richResultTypesCount} eligible. Add more rich result types`); } else { steps.push(`Rich results (35%): All ${richResultTypesCount} types eligible`); } if (coverage < 100) { steps.push(`Coverage (20%): ${coverage.toFixed(1)}% - Add schema to pages without markup`); } else { steps.push(`Coverage (20%): 100% - All pages have schema`); } const uniqueTypesCount = allTypes.size; if (uniqueTypesCount < 15) { steps.push(`Diversity (15%): ${uniqueTypesCount} unique types. Add more schema types to reach 15+`); } else { steps.push(`Diversity (15%): ${uniqueTypesCount} unique types (excellent)`); } } else { steps.push('Schema audit data not available - run audit to see detailed metrics'); } break; case 'visibility': if (searchData) { const position = searchData.averagePosition || 0; const ctr = searchData.ctr || 0; if (position > 10) { steps.push(`Average position: ${position.toFixed(1)} - Target top 10 positions`); } else { steps.push(`Average position: ${position.toFixed(1)} - Excellent! Maintain top 10 rankings`); } if (ctr < 2.0) { steps.push(`CTR: ${ctr.toFixed(1)}% - Improve click-through rate (target: 2%+)`); } else { steps.push(`CTR: ${ctr.toFixed(1)}% - Good CTR! Continue optimizing`); } } break; case 'authority': if (searchData) { const ctr = searchData.ctr || 0; const position = searchData.averagePosition || 0; if (ctr < 1.5) { steps.push(`CTR: ${ctr.toFixed(1)}% - Low click-through indicates trust issues. Improve E-A-T signals`); } else { steps.push(`CTR: ${ctr.toFixed(1)}% - Good engagement. Build more backlinks to strengthen authority`); } if (position > 15) { steps.push(`Position: ${position.toFixed(1)} - Improve rankings through comprehensive, expert content`); } } break; case 'localEntity': if (hasLocalSignals && localSignalsData) { const napScore = localSignalsData.napConsistencyScore !== null ? localSignalsData.napConsistencyScore : 'N/A'; const locationsCount = localSignalsData.locations?.length || 0; const knowledgePanel = localSignalsData.knowledgePanelDetected ? 'detected' : 'not detected'; steps.push(`Current score: ${Math.round(score)} - Using real Business Profile data`); steps.push(`Data: NAP consistency: ${napScore}%, Knowledge panel: ${knowledgePanel}, Locations: ${locationsCount}`); if (score < 70) { if (napScore < 100) { steps.push(`Action: Improve NAP consistency (currently ${napScore}%)`); } if (!localSignalsData.knowledgePanelDetected) { steps.push(`Action: Work on knowledge panel detection`); } } } else { steps.push(`Current score: ${Math.round(score)} - Using derived calculation from search performance`); steps.push(`Priority: Integrate Google Business Profile API to use real local signals data`); if (score < 70) { steps.push(`Action: Add LocalBusiness schema markup and ensure NAP consistency`); } } break; case 'serviceArea': if (hasLocalSignals && localSignalsData) { const serviceAreasCount = localSignalsData.serviceAreas?.length || 0; const napScore = localSignalsData.napConsistencyScore !== null ? localSignalsData.napConsistencyScore : 'N/A'; steps.push(`Current score: ${Math.round(score)} - Using real Business Profile data`); steps.push(`Data: Service areas: ${serviceAreasCount}, NAP consistency: ${napScore}%`); if (score < 70) { if (serviceAreasCount < 5) { steps.push(`Action: Add more service areas (currently ${serviceAreasCount}, target: 5+)`); } if (napScore < 100) { steps.push(`Action: Improve NAP consistency (currently ${napScore}%)`); } } } else { steps.push(`Current score: ${Math.round(score)} - Using derived calculation from Local Entity`); steps.push(`Priority: Integrate Google Business Profile API to get real service area data`); if (score < 70) { steps.push(`Action: Add ServiceArea schema and create location-specific pages`); } } break; } if (steps.length === 0) { if (score >= 70) { steps.push('Maintain current performance'); steps.push('Monitor for any score drops'); } else if (score >= 40) { steps.push('Focus on improving this pillar'); steps.push('Review specific metrics above'); } else { steps.push('Critical: Immediate action required'); steps.push('Review all data sources and implement fixes'); } } nextSteps[key] = steps; }); return nextSteps; }; const nextSteps = getNextStepsForPDF(scores, searchData, schemaAudit); return `

    GAIO Audit Report

    Property: ${propertyUrl}
    Date Range: Last ${dateRange} days
    Audit Date: ${new Date(auditDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
    Executive Summary

    Overall Snippet Readiness: ${snippetReadiness}% ${getRAGStatus(snippetReadiness).text}

    The Snippet Readiness score indicates how likely your content is to appear in featured snippets and AI answers. It combines Content/Schema (40%), Visibility (35%), and Authority (25%).

    Pillar Scores Overview
    ${Object.entries(scores).map(([key, score]) => { const rag = getRAGStatus(score); const pillarNames = { localEntity: 'Local Entity', serviceArea: 'Service Area', authority: 'Authority', visibility: 'Visibility', contentSchema: 'Content/Schema' }; return `

    ${pillarNames[key] || key}

    ${score}%
    ${rag.label} - ${rag.text}
    `; }).join('')}
    Visual Analytics
    Pillar Scores Radar Chart
    ${chartImages.radarChart ? 'Radar Chart' : '

    Chart not available

    '}

    Radar chart showing current performance across all five pillars. The larger the area, the stronger your overall entity recognition.

    Performance Trends
    ${chartImages.trendChart ? 'Trend Chart' : '

    Chart not available

    '}

    Historical performance trends showing clicks, impressions, CTR, position, and Content/Schema scores over the selected date range.

    Snippet Readiness Gauge
    ${chartImages.snippetReadinessChart ? 'Snippet Readiness Chart' : '

    Chart not available

    '}

    Nested doughnut chart showing weighted breakdown of snippet readiness components with actual performance scores.

    Key Metrics
    ${(searchData.totalClicks || 0).toLocaleString()}
    Total Clicks
    ${(searchData.totalImpressions || 0).toLocaleString()}
    Total Impressions
    ${(searchData.ctr || 0).toFixed(1)}%
    Average CTR
    ${(searchData.averagePosition || 0).toFixed(1)}
    Average Position
    Pillar Definitions & Current Status
    ${Object.entries(scores).map(([key, score]) => { const rag = getRAGStatus(score); const pillarNames = { localEntity: 'Local Entity', serviceArea: 'Service Area', authority: 'Authority', visibility: 'Visibility', contentSchema: 'Content/Schema' }; // Build definitions dynamically based on whether we have real Business Profile data let localEntityDef, serviceAreaDef; if (hasLocalSignals && localSignalsData) { const napScore = localSignalsData.napConsistencyScore !== null ? localSignalsData.napConsistencyScore : 'N/A'; const serviceAreasCount = localSignalsData.serviceAreas?.length || 0; const locationsCount = localSignalsData.locations?.length || 0; localEntityDef = `Measures how well your business is recognized as a local entity. Uses real data from Google Business Profile API: NAP consistency (${napScore}%), knowledge panel (${localSignalsData.knowledgePanelDetected ? 'detected' : 'not detected'}), locations (${locationsCount}).`; serviceAreaDef = `Assesses your service area coverage and geographic relevance. Uses real data from Google Business Profile API: ${serviceAreasCount} service areas, NAP consistency (${napScore}%).`; } else { localEntityDef = 'Measures how well your business is recognized as a local entity. Based on LocalBusiness schema presence, NAP consistency, and knowledge panel detection. Currently uses derived calculations from GSC data.'; serviceAreaDef = 'Assesses your service area coverage and geographic relevance. Derived from Local Entity score. Will use real service area data when Google Business Profile API is integrated.'; } const definitions = { localEntity: localEntityDef, serviceArea: serviceAreaDef, authority: 'Evaluates your domain authority and trust signals. Calculated from four components: Behaviour Score (40%): CTR for ranking queries + top-10 CTR. Ranking Score (20%): Average position + top-10 impression share. Backlink Score (20%): Referring domains + quality from CSV upload. Review Score (20%): Combined ratings and counts from Google Business Profile + on-site/Trustpilot reviews.', visibility: 'Tracks your search visibility and ranking performance. Based on average position from Google Search Console (1 = best, 40 = worst). Score ranges from 10 to 100.', contentSchema: 'Measures schema markup quality and completeness. Weighted calculation: Foundation Schemas (30%), Rich Results (35%), Coverage (20%), Diversity (15%).' }; return `

    ${pillarNames[key] || key} - ${score}% ${rag.label}

    ${definitions[key] || 'No definition available.'}

    `; }).join('')}
    Recommended Next Steps
    ${Object.entries(nextSteps).map(([pillar, steps]) => { if (!steps || steps.length === 0) return ''; const pillarNames = { localEntity: 'Local Entity', serviceArea: 'Service Area', authority: 'Authority', visibility: 'Visibility', contentSchema: 'Content/Schema' }; return `

    ${pillarNames[pillar] || pillar}

      ${steps.map(step => `
    • ${step}
    • `).join('')}
    `; }).join('')}
    ${schemaAudit.data ? `
    Schema Audit Summary
    ${schemaAudit.data.totalPages || 0}
    Total Pages Scanned
    ${schemaAudit.data.pagesWithSchema || 0}
    Pages With Schema
    ${(schemaAudit.data.coverage || 0).toFixed(1)}%
    Schema Coverage
    ${(schemaAudit.data.schemaTypes || []).length}
    Schema Types Found
    ${schemaAudit.data.missingSchemaPages && schemaAudit.data.missingSchemaPages.length > 0 ? `

    Pages Missing Schema (${schemaAudit.data.missingSchemaPages.length}):

      ${schemaAudit.data.missingSchemaPages.slice(0, 20).map(url => `
    • ${url}
    • `).join('')} ${schemaAudit.data.missingSchemaPages.length > 20 ? `
    • ... and ${schemaAudit.data.missingSchemaPages.length - 20} more
    • ` : ''}
    ` : ''}
    ` : ''} ${searchData.topQueries && searchData.topQueries.length > 0 ? `
    Top Queries
    ${searchData.topQueries.slice(0, 20).map(query => ` `).join('')}
    Query Clicks Impressions CTR Position
    ${query.query || 'N/A'} ${(query.clicks || 0).toLocaleString()} ${(query.impressions || 0).toLocaleString()} ${(query.ctr || 0).toFixed(1)}% ${(query.position || 0).toFixed(1)}
    ` : ''} `; } // ====================== // Ranking & AI: Progress Modal Functions // ====================== const RankingAiProgressModal = { steps: [ { id: 'init', label: 'Initializing', narrative: 'Preparing to fetch ranking and AI data...' }, { id: 'serp', label: 'Fetching SERP Rankings', narrative: 'Retrieving search engine rankings and search volume data...' }, { id: 'ai', label: 'Fetching AI Overview Data', narrative: 'Checking AI Overview presence and citations...' }, { id: 'process', label: 'Processing Results', narrative: 'Combining data and calculating metrics...' }, { id: 'save', label: 'Saving Data', narrative: 'Storing results to database...' }, { id: 'complete', label: 'Complete', narrative: 'Ranking & AI check completed successfully!' } ], show() { const modal = document.getElementById('rankingAiProgressModal'); if (modal) { modal.style.display = 'block'; this.updateProgress(0); this.renderSteps(); // Hide summary section when starting new scan const summaryEl = document.getElementById('rankingAiSummary'); if (summaryEl) { summaryEl.style.display = 'none'; } // Disable close button during processing const closeBtn = document.getElementById('rankingAiProgressClose'); if (closeBtn) { closeBtn.disabled = true; closeBtn.style.opacity = '0.5'; closeBtn.onclick = null; } } }, hide() { const modal = document.getElementById('rankingAiProgressModal'); if (modal) { modal.style.display = 'none'; } }, updateProgress(percent, stepIndex = null) { const fill = document.getElementById('rankingAiProgressFill'); const text = document.getElementById('rankingAiProgressText'); if (fill) fill.style.width = `${Math.min(100, Math.max(0, percent))}%`; if (text) text.textContent = `${Math.round(percent)}%`; if (stepIndex !== null) { this.setActiveStep(stepIndex); } }, setActiveStep(stepIndex) { const steps = document.querySelectorAll('.ranking-ai-step-item'); steps.forEach((step, idx) => { step.classList.remove('active', 'completed', 'pending'); const icon = step.querySelector('.ranking-ai-step-icon'); if (idx < stepIndex) { step.classList.add('completed'); if (icon) icon.textContent = '✓'; } else if (idx === stepIndex) { step.classList.add('active'); if (icon) icon.textContent = idx + 1; } else { step.classList.add('pending'); if (icon) icon.textContent = idx + 1; } }); const step = this.steps[stepIndex]; if (step) { const currentStepEl = document.getElementById('rankingAiCurrentStep'); const narrativeEl = document.getElementById('rankingAiStepNarrative'); if (currentStepEl) currentStepEl.textContent = step.label; if (narrativeEl) narrativeEl.textContent = step.narrative; } }, updateCounts(text) { const countsEl = document.getElementById('rankingAiStepCounts'); if (countsEl) countsEl.textContent = text; }, renderSteps() { const listEl = document.getElementById('rankingAiStepsList'); if (!listEl) return; listEl.innerHTML = this.steps.map((step, idx) => `
    ${idx + 1}
    ${step.label}
    `).join(''); }, showSummary(summary) { const summaryEl = document.getElementById('rankingAiSummary'); const summaryContentEl = document.getElementById('rankingAiSummaryContent'); if (!summaryEl || !summaryContentEl) return; // Build summary HTML const summaryItems = []; // Handle error case if (summary.error) { summaryItems.push(`
    Error:
    ${summary.errorMessage || 'Unknown error occurred'}
    `); } if (summary.totalKeywords !== undefined) { summaryItems.push(`
    Total Keywords:
    ${summary.totalKeywords}
    `); } if (summary.keywordsWithRank !== undefined) { summaryItems.push(`
    With Rankings:
    ${summary.keywordsWithRank}
    `); } if (summary.top10 !== undefined) { summaryItems.push(`
    Top 10 Rankings:
    ${summary.top10}
    `); } if (summary.top3 !== undefined) { summaryItems.push(`
    Top 3 Rankings:
    ${summary.top3}
    `); } if (summary.keywordsWithAiOverview !== undefined) { summaryItems.push(`
    AI Overview Present:
    ${summary.keywordsWithAiOverview}
    `); } if (summary.keywordsWithAiCitations !== undefined) { summaryItems.push(`
    AI Citations:
    ${summary.keywordsWithAiCitations}
    `); } if (summary.avgPositionUnweighted !== null && summary.avgPositionUnweighted !== undefined) { summaryItems.push(`
    Avg Position (Unweighted):
    ${summary.avgPositionUnweighted.toFixed(1)}
    `); } if (summary.avgPositionVolumeWeighted !== null && summary.avgPositionVolumeWeighted !== undefined) { summaryItems.push(`
    Avg Position (Weighted):
    ${summary.avgPositionVolumeWeighted.toFixed(1)}
    `); } if (summary.keywordsWithVolume !== undefined) { summaryItems.push(`
    With Search Volume:
    ${summary.keywordsWithVolume}
    `); } summaryContentEl.innerHTML = summaryItems.join(''); summaryEl.style.display = 'block'; } }; // Make progress modal globally available window.RankingAiProgressModal = RankingAiProgressModal; // ====================== // Ranking & AI: Save data to Supabase and localStorage // ====================== /** * Save a single batch of keywords incrementally to Supabase keyword_rankings table * This allows partial results to be saved even if later batches fail */ async function saveRankingAiDataIncremental(batchRows, auditDate, propertyUrl) { try { if (!batchRows || batchRows.length === 0) { return { success: true, saved: 0 }; } // Prepare keyword rows for insertion const keywordRows = batchRows.map(row => ({ audit_date: auditDate, property_url: String(propertyUrl).trim(), keyword: String(row.keyword || '').trim(), best_rank_group: row.best_rank_group !== null && row.best_rank_group !== undefined ? parseInt(row.best_rank_group) : null, best_rank_absolute: row.best_rank_absolute !== null && row.best_rank_absolute !== undefined ? parseInt(row.best_rank_absolute) : null, best_url: row.best_url ? String(row.best_url).trim() : null, best_title: row.best_title ? String(row.best_title).trim() : null, search_volume: row.search_volume !== null && row.search_volume !== undefined ? parseInt(row.search_volume) : null, has_ai_overview: row.has_ai_overview === true, ai_total_citations: row.ai_total_citations !== null && row.ai_total_citations !== undefined ? parseInt(row.ai_total_citations) : null, ai_alan_citations_count: row.ai_alan_citations_count !== null && row.ai_alan_citations_count !== undefined ? parseInt(row.ai_alan_citations_count) : null, ai_alan_citations: row.ai_alan_citations ? (Array.isArray(row.ai_alan_citations) ? row.ai_alan_citations : []) : null, competitor_counts: row.competitor_counts ? (typeof row.competitor_counts === 'object' ? row.competitor_counts : {}) : null, serp_features: row.serp_features ? (typeof row.serp_features === 'object' ? row.serp_features : {}) : null, // New boolean fields for SERP feature coverage ai_overview_present_any: row.ai_overview_present_any === true || row.has_ai_overview === true, local_pack_present_any: row.local_pack_present_any === true || (row.serp_features && row.serp_features.local_pack === true), paa_present_any: row.paa_present_any === true || (row.serp_features && row.serp_features.people_also_ask === true), featured_snippet_present_any: row.featured_snippet_present_any === true || (row.serp_features && row.serp_features.featured_snippet === true), segment: row.segment ? String(row.segment).trim() : null, page_type: row.pageType ? String(row.pageType).trim() : null, demand_share: row.demand_share !== null && row.demand_share !== undefined ? parseFloat(row.demand_share) : null, opportunity_score: row.opportunityScore !== null && row.opportunityScore !== undefined ? parseInt(row.opportunityScore) : null, updated_at: new Date().toISOString() })); // Use upsert endpoint to save batch (will merge duplicates based on unique constraint) const response = await fetch(apiUrl('/api/supabase/save-keyword-batch'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keywordRows, auditDate, propertyUrl }) }); if (response.ok) { const responseData = await response.json(); debugLog(`✓ Incremental save: ${batchRows.length} keywords saved to keyword_rankings`, 'success'); return { success: true, saved: batchRows.length }; } else { const errorText = await response.text(); debugLog(`⚠ Incremental save failed: ${response.status} - ${errorText}`, 'warn'); return { success: false, saved: 0, error: errorText }; } } catch (err) { debugLog(`✗ Error in incremental save: ${err.message}`, 'error'); return { success: false, saved: 0, error: err.message }; } } async function saveRankingAiData(combinedRows, summary) { try { const propertyUrl = localStorage.getItem('gsc_property_url') || localStorage.getItem('last_property_url') || ''; if (!propertyUrl) { debugLog('⚠ Cannot save Ranking & AI data: property URL not set', 'warn'); return; } // Save to localStorage first const rankingAiData = { combinedRows, summary, timestamp: new Date().toISOString() }; localStorage.setItem('rankingAiData', JSON.stringify(rankingAiData)); debugLog('✓ Ranking & AI data saved to localStorage', 'success'); // Dashboard: refresh live dials/cards immediately when Ranking & AI saves if (typeof window.renderDashboardTab === 'function') { try { window.renderDashboardTab(); } catch {} } // Save to Supabase const auditDate = new Date().toISOString().split('T')[0]; const response = await fetch(apiUrl('/api/supabase/save-audit'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ propertyUrl, auditDate, rankingAiData // New field for ranking AI data }) }); if (response.ok) { const responseData = await response.json(); debugLog(`✓ Ranking & AI data saved to Supabase (${combinedRows.length} keywords)`, 'success'); if (responseData.data) { debugLog(`✓ Supabase confirmed save: ${JSON.stringify(responseData.data).substring(0, 200)}...`, 'info'); } // Keep Portfolio AI metrics in sync: update portfolio_segment_metrics_28d AI fields from keyword_rankings // (important for single-URL segments like "Academy") try { const aiResp = await fetch(apiUrl('/api/supabase/backfill-ai-portfolio-segments'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ auditDate, siteUrl: propertyUrl }) }); if (aiResp.ok) { debugLog('✓ Portfolio AI metrics refreshed from keyword_rankings', 'success'); if (typeof renderPortfolioTable === 'function') { renderPortfolioTable(); } } else { const t = await aiResp.text(); debugLog(`⚠ Portfolio AI metrics refresh failed: ${aiResp.status} - ${t}`, 'warn'); } } catch (aiSyncErr) { debugLog(`⚠ Portfolio AI metrics refresh failed: ${aiSyncErr.message}`, 'warn'); } } else { const errorText = await response.text(); debugLog(`⚠ Failed to save Ranking & AI data to Supabase: ${response.status} - ${errorText}`, 'warn'); console.error('[Save Ranking & AI] Full error response:', errorText); } } catch (err) { debugLog(`✗ Error saving Ranking & AI data: ${err.message}`, 'error'); console.error('Save Ranking & AI data error:', err); } } function classifyPortfolioSegmentFromUrl(rawUrl) { if (!rawUrl) return null; const canonical = (typeof normalizeGscPageKey === 'function') ? normalizeGscPageKey(rawUrl) : String(rawUrl); const lower = String(canonical).toLowerCase(); if (!lower.includes('alanranger.com')) return null; // Academy + Blog overrides (even though pageSegment marks academy as education) if (lower.includes('/free-online-photography-course')) return 'academy'; if (lower.includes('/blog-on-photography/')) return 'blog'; // Use the canonical page segment classifier for money vs non-money try { if (typeof classifyPageSegment === 'function' && typeof PageSegment !== 'undefined') { const seg = classifyPageSegment(canonical); if (seg !== PageSegment.MONEY) return 'other'; } } catch { // If classifier fails, treat as Other return 'other'; } // Money → use the same sub-segment heuristics as backend (stable and not dependent on Ranking&AI pageType) // Event pages if (lower.includes('/beginners-photography-lessons') || lower.includes('/photographic-workshops-near-me')) { return 'event'; } // Product pages if (lower.includes('/photo-workshops-uk') || lower.includes('/photography-services-near-me')) { return 'product'; } // Default money bucket return 'landing'; } function computeAiCitationsByCitedUrlSegment(rows) { const segCounts = { landing: 0, event: 0, product: 0, academy: 0, blog: 0, other: 0, money: 0, site: 0 }; const inferSegs = (url) => { const seg = classifyPortfolioSegmentFromUrl(url); if (!seg) return []; if (seg === 'landing' || seg === 'event' || seg === 'product') return [seg, 'money']; return [seg]; }; const extractCitedUrls = (row) => { const raw = row?.ai_alan_citations || row?.aiAlanCitations || []; if (!raw || !Array.isArray(raw)) return []; return raw .map(v => { if (!v) return null; if (typeof v === 'string') return v; if (typeof v === 'object' && v.url) return v.url; return null; }) .filter(Boolean); }; const safeRows = Array.isArray(rows) ? rows : []; safeRows.forEach(r => { const cCount = parseInt(r.ai_alan_citations_count ?? r.aiAlanCitationsCount ?? 0, 10) || 0; segCounts.site += cCount; const citedUrls = extractCitedUrls(r); citedUrls.forEach(u => { inferSegs(u).forEach(seg => { if (segCounts[seg] === undefined) return; segCounts[seg] += 1; }); }); }); return segCounts; } function renderRankingAiMoneyCitationsTile(rankingAiData) { const pill = document.getElementById('ranking-card-ai-citations-money'); if (!pill) return; const valueEl = pill.querySelector('[data-field="value"]'); const statusEl = pill.querySelector('[data-field="status"]'); const detailsEl = document.getElementById('ranking-card-ai-citations-money-details'); const rows = rankingAiData?.combinedRows || []; const counts = computeAiCitationsByCitedUrlSegment(rows); const moneyCitations = counts.money || 0; const totalCitations = counts.site || 0; const share = totalCitations > 0 ? (moneyCitations / totalCitations) : 0; const sharePct = Math.round(share * 100); const sumFromUrls = (counts.landing || 0) + (counts.event || 0) + (counts.product || 0) + (counts.academy || 0) + (counts.blog || 0) + (counts.other || 0); const unattributed = Math.max(0, (totalCitations || 0) - sumFromUrls); const otherTotal = (counts.other || 0) + unattributed; if (valueEl) valueEl.textContent = `${moneyCitations}/${totalCitations} (${sharePct}%)`; // RAG thresholds (money share of citations): // - Green: >= 70% // - Amber: 50-69% // - Red: < 50% let ragClass = 'red'; let statusLabel = 'Low'; if (sharePct >= 70) { ragClass = 'green'; statusLabel = 'Strong'; } else if (sharePct >= 50) { ragClass = 'amber'; statusLabel = 'Moderate'; } pill.className = `metric-pill metric-pill--${ragClass}`; if (statusEl) statusEl.textContent = statusLabel; if (detailsEl) { detailsEl.innerHTML = `
    How many citations point to money pages (Landing/Event/Product), based on the cited URLs.
    Money citations: ${moneyCitations} (${sharePct}%)
    Total citations: ${totalCitations}
    Breakdown (cited URLs)
    Landing${counts.landing || 0}
    Event${counts.event || 0}
    Product${counts.product || 0}
    Academy${counts.academy || 0}
    Blog${counts.blog || 0}
    Other (non-money)${otherTotal}
    ${unattributed > 0 ? `Includes ${unattributed} unattributed citations (counted in totals, but no URL captured).` : `All citations were attributed to captured URLs.`}
    `; } } // ====================== // Portfolio: Segment URLs modal (Segment pages + AI-cited pages) // ====================== function ensurePortfolioSegmentUrlsModal() { if (document.getElementById('portfolio-segment-urls-modal')) return; const modalHtml = ` `; document.body.insertAdjacentHTML('beforeend', modalHtml); const modal = document.getElementById('portfolio-segment-urls-modal'); const closeBtn = document.getElementById('portfolio-segment-urls-modal-close'); if (closeBtn && modal) closeBtn.addEventListener('click', () => { modal.style.display = 'none'; }); if (modal) { modal.addEventListener('click', (e) => { if (e.target === modal) modal.style.display = 'none'; }); } const tabBtns = Array.from(document.querySelectorAll('#portfolio-segment-urls-modal .portfolio-segment-tab-btn')); tabBtns.forEach(btn => { btn.addEventListener('click', () => { tabBtns.forEach(b => { const active = b === btn; b.style.background = active ? '#1f2a44' : 'transparent'; }); modal.dataset.activeTab = btn.dataset.tab; // Re-render from cached content if (typeof window.__renderPortfolioSegmentUrlsModalFromCache === 'function') { window.__renderPortfolioSegmentUrlsModalFromCache(); } }); }); const copyBtn = document.getElementById('portfolio-segment-urls-copy'); if (copyBtn) { copyBtn.addEventListener('click', async () => { const txt = modal?.dataset?.copyText || ''; if (!txt) return; try { await navigator.clipboard.writeText(txt); copyBtn.textContent = 'Copied'; setTimeout(() => { copyBtn.textContent = 'Copy list'; }, 900); } catch { // ignore } }); } } function getPortfolioSegmentLabel(seg) { const labels = { site: 'Entire site', money: 'Money Pages', money_tracked: 'Money Pages (tracked)', landing: 'Landing', event: 'Event', product: 'Product', academy: 'Academy', blog: 'Blog Pages', other: 'Other (non-money)', all_tracked: 'All tracked' }; return labels[seg] || seg; } function inferPortfolioSegmentForPageUrl(pageUrl) { if (!pageUrl) return null; return classifyPortfolioSegmentFromUrl(pageUrl) || 'other'; } async function openPortfolioSegmentUrlsModal(segmentKey) { ensurePortfolioSegmentUrlsModal(); const modal = document.getElementById('portfolio-segment-urls-modal'); if (!modal) return; modal.style.display = 'block'; // Default tab based on the KPI context: // - When looking at AI metrics, users typically want the AI-cited URLs first. // - For traffic KPIs, segment pages are the natural default. const kpi = document.getElementById('portfolio-table-kpi-select')?.value || 'ctr_28d'; modal.dataset.activeTab = (kpi === 'ai_citations' || kpi === 'ai_overview') ? 'cited' : 'segment'; const titleEl = document.getElementById('portfolio-segment-urls-modal-title'); const subtitleEl = document.getElementById('portfolio-segment-urls-modal-subtitle'); const statusEl = document.getElementById('portfolio-segment-urls-modal-status'); const contentEl = document.getElementById('portfolio-segment-urls-modal-content'); if (titleEl) titleEl.textContent = `${getPortfolioSegmentLabel(segmentKey)} — URLs`; if (subtitleEl) subtitleEl.textContent = 'Loading latest snapshot…'; if (statusEl) statusEl.textContent = 'Loading…'; if (contentEl) contentEl.innerHTML = ''; const propertyUrl = (window.getPropertyUrl ? window.getPropertyUrl() : '') || localStorage.getItem('gsc_property_url') || localStorage.getItem('last_property_url') || ''; if (!propertyUrl) { if (statusEl) statusEl.textContent = 'No property URL found.'; return; } // Fetch latest page snapshot from Supabase (server decides latest date range). const pagesResp = await fetch(apiUrl(`/api/supabase/get-gsc-page-metrics?siteUrl=${encodeURIComponent(propertyUrl)}`)); if (!pagesResp.ok) { if (statusEl) statusEl.textContent = `Failed to load pages: ${pagesResp.status}`; return; } const pagesJson = await pagesResp.json(); const pages = Array.isArray(pagesJson.pages) ? pagesJson.pages : []; const snapshotStart = pages[0]?.date_start ? String(pages[0].date_start).slice(0, 10) : null; const snapshotEnd = pages[0]?.date_end ? String(pages[0].date_end).slice(0, 10) : null; // Latest AI audit date (from keyword_rankings), via portfolio AI backfill endpoint logic is heavier; do a small API-less heuristic: // We rely on Ranking & AI audits being saved with auditDate=YYYY-MM-DD, and use today in UI. // Fallback: use today. const aiAuditDate = new Date().toISOString().slice(0, 10); modal.__cache = { segmentKey, snapshotStart, snapshotEnd, pages, aiAuditDate, citedUrls: null }; if (subtitleEl) { subtitleEl.textContent = snapshotStart && snapshotEnd ? `Latest 28d snapshot: ${snapshotStart} → ${snapshotEnd}` : 'Latest 28d snapshot'; } // Load cited urls lazily when needed window.__renderPortfolioSegmentUrlsModalFromCache = async () => { const cache = modal.__cache || {}; const activeTab = modal.dataset.activeTab || 'segment'; if (statusEl) statusEl.textContent = ''; if (contentEl) contentEl.innerHTML = ''; const renderList = (items, metaText) => { const list = Array.isArray(items) ? items : []; modal.dataset.copyText = list.join('\n'); const meta = `
    ${metaText}
    `; const rows = list.slice(0, 500).map(u => ``).join(''); const more = list.length > 500 ? `
    Showing first 500 of ${list.length}. Use “Copy list” for full.
    ` : ''; if (contentEl) contentEl.innerHTML = `${meta}${rows}${more}`; }; if (activeTab === 'segment') { const seg = cache.segmentKey; const filtered = cache.pages .filter(p => inferPortfolioSegmentForPageUrl(p.page_url) === seg || (seg === 'money' && ['landing','event','product'].includes(inferPortfolioSegmentForPageUrl(p.page_url)))) .sort((a, b) => (parseFloat(b.impressions_28d) || 0) - (parseFloat(a.impressions_28d) || 0)) .map(p => String(p.page_url)); const label = getPortfolioSegmentLabel(seg); renderList(filtered, `${label}: ${filtered.length.toLocaleString()} pages in latest snapshot (sorted by impressions).`); return; } // cited tab if (!cache.citedUrls) { if (statusEl) statusEl.textContent = 'Loading AI cited URLs…'; // Pull latest keyword_rankings from Supabase via existing data loader in localStorage if available let rows = []; try { const local = localStorage.getItem('rankingAiData'); const parsed = local ? JSON.parse(local) : null; rows = Array.isArray(parsed?.combinedRows) ? parsed.combinedRows : []; } catch {} // If no localStorage, we can’t easily query keyword_rankings without an API endpoint; show a hint. if (!rows || rows.length === 0) { cache.citedUrls = []; if (statusEl) statusEl.textContent = 'No Ranking & AI data found in this browser. Run a Ranking & AI check, then re-open this modal.'; } else { const urls = []; rows.forEach(r => { const cited = r.ai_alan_citations || r.aiAlanCitations || []; if (Array.isArray(cited)) { cited.forEach(v => { const u = typeof v === 'string' ? v : (v && typeof v === 'object' ? v.url : null); if (u) urls.push(String(u)); }); } }); // Canonicalize so text fragments and tracking params don't break classification. const canonicalItems = urls .map(u => (typeof normalizeGscPageKey === 'function') ? normalizeGscPageKey(u) : u) .filter(Boolean); cache.citedUrlItems = canonicalItems; // may include duplicates (citation items) cache.citedUrls = Array.from(new Set(canonicalItems)); // unique URLs // Unattributed citations = keyword-reported citation counts with no URL captured try { const totalCitations = rows.reduce((s, r) => s + (parseInt(r.ai_alan_citations_count ?? r.aiAlanCitationsCount ?? 0, 10) || 0), 0); const urlItemCount = canonicalItems.length; cache.unattributedCitations = Math.max(0, totalCitations - urlItemCount); } catch { cache.unattributedCitations = 0; } } } const seg = cache.segmentKey; const citedFiltered = (cache.citedUrls || []) .filter(u => { const s = inferPortfolioSegmentForPageUrl(u); if (seg === 'money') return ['landing','event','product'].includes(s); return s === seg; }); const citedItemsCount = (cache.citedUrlItems || []).filter(u => { const s = inferPortfolioSegmentForPageUrl(u); if (seg === 'money') return ['landing','event','product'].includes(s); return s === seg; }).length; const label = getPortfolioSegmentLabel(seg); const extra = (seg === 'other' && (cache.unattributedCitations || 0) > 0) ? ` + ${(cache.unattributedCitations || 0).toLocaleString()} unattributed (no URL captured)` : ''; const citedItemsWithUnattributed = seg === 'other' ? (citedItemsCount + (cache.unattributedCitations || 0)) : citedItemsCount; renderList( citedFiltered, `${label}: ${citedFiltered.length.toLocaleString()} unique AI-cited URLs (${citedItemsWithUnattributed.toLocaleString()} citation items${extra}) from latest Ranking & AI data in this browser.` ); }; await window.__renderPortfolioSegmentUrlsModalFromCache(); } // ====================== // Ranking & AI: Normalize summary field names (snake_case to camelCase) // ====================== function normalizeSummaryFields(summary) { if (!summary) return summary; // Convert snake_case to camelCase for display compatibility return { totalKeywords: summary.total_keywords ?? summary.totalKeywords, keywordsWithRank: summary.keywords_with_rank ?? summary.keywordsWithRank, keywordsWithAiOverview: summary.keywords_with_ai_overview ?? summary.keywordsWithAiOverview, keywordsWithAiCitations: summary.keywords_with_ai_citations ?? summary.keywordsWithAiCitations, top10: summary.top10, top3: summary.top3, avgPositionUnweighted: summary.avg_position_unweighted ?? summary.avgPositionUnweighted, avgPositionVolumeWeighted: summary.avg_position_volume_weighted ?? summary.avgPositionVolumeWeighted, keywordsUsedForAvg: summary.keywords_used_for_avg ?? summary.keywordsUsedForAvg, keywordsWithVolume: summary.keywords_with_volume ?? summary.keywordsWithVolume, // v1.4: Domain Strength context (read-only) domainStrength: summary.domainStrength ?? null, authorityPriority: summary.authorityPriority ?? null }; } // ====================== // Ranking & AI: Compute AI metrics for a page URL // ====================== // This function looks up AI Overview and AI Citations for a given URL by matching against ranking data window.computeAiMetricsForPageUrl = function computeAiMetricsForPageUrl(pageUrl, rankingRows) { if (!pageUrl || !rankingRows || !Array.isArray(rankingRows) || rankingRows.length === 0) { return { ai_overview: false, ai_citations: null }; } // Normalize the page URL for matching const normalizedPageUrl = (pageUrl || '').toLowerCase() .replace(/^https?:\/\//, '') .replace(/^www\./, '') .replace(/\/$/, ''); // Try to find matching rows by URL const matchingRows = rankingRows.filter(row => { const rowUrl = (row.best_url || row.targetUrl || row.ranking_url || '').toLowerCase(); const normalizedRowUrl = rowUrl .replace(/^https?:\/\//, '') .replace(/^www\./, '') .replace(/\/$/, ''); return normalizedRowUrl === normalizedPageUrl; }); // If no URL match, return default if (matchingRows.length === 0) { return { ai_overview: false, ai_citations: null }; } // Use the first matching row (or aggregate if multiple) const bestMatch = matchingRows[0]; const aiOverview = bestMatch.has_ai_overview === true || bestMatch.ai_overview_present_any === true; const aiCitations = bestMatch.ai_alan_citations_count != null ? bestMatch.ai_alan_citations_count : (bestMatch.ai_total_citations != null ? bestMatch.ai_total_citations : null); return { ai_overview: aiOverview, ai_citations: aiCitations }; }; // Ranking & AI: Domain Strength context (read-only) // ====================== let __rankingAiAuthorityContext = null; async function fetchRankingAiAuthorityContext() { if (__rankingAiAuthorityContext) return __rankingAiAuthorityContext; try { const resp = await fetch(apiUrl('/api/ranking-ai/summary')); const json = await resp.json(); if (!resp.ok || !json || json.status !== 'ok') { __rankingAiAuthorityContext = { domainStrength: null, authorityPriority: null }; return __rankingAiAuthorityContext; } __rankingAiAuthorityContext = { domainStrength: json.domainStrength || null, authorityPriority: json.authorityPriority || null }; return __rankingAiAuthorityContext; } catch { __rankingAiAuthorityContext = { domainStrength: null, authorityPriority: null }; return __rankingAiAuthorityContext; } } // ====================== // Ranking & AI: Load data from localStorage or Supabase // ====================== async function loadRankingAiDataFromStorage(forceCheckSupabase = false) { try { const propertyUrl = (window.getPropertyUrl ? window.getPropertyUrl() : '') || localStorage.getItem('gsc_property_url') || localStorage.getItem('last_property_url') || ''; if (!propertyUrl) { debugLog('⚠ Cannot load Ranking & AI data: property URL not set', 'warn'); return null; } // Store localStorage data as fallback (in case Supabase check fails) let localStorageFallback = null; // If forcing Supabase check, skip localStorage if (!forceCheckSupabase) { // Try localStorage first const localData = localStorage.getItem('rankingAiData'); if (localData) { try { const parsed = JSON.parse(localData); if (parsed.combinedRows && parsed.summary) { debugLog('✓ Ranking & AI data found in localStorage', 'success'); debugLog(` Found ${parsed.combinedRows.length} keywords in localStorage`, 'info'); // Re-classify pageType for all rows (fixes stale values) if (typeof window.classifyUrlForRankingAi === 'function') { let reclassifiedCount = 0; parsed.combinedRows.forEach(row => { const bestUrl = row.best_url || row.bestUrl || null; if (bestUrl) { const oldPageType = row.pageType || 'Landing'; const classification = window.classifyUrlForRankingAi(bestUrl, row.keyword || null); const newPageType = classification.pageType; if (newPageType !== oldPageType) { row.pageType = newPageType; if (classification.segment && classification.segment !== row.segment) { row.segment = classification.segment; } reclassifiedCount++; } } }); if (reclassifiedCount > 0) { debugLog(`✓ Re-classified pageType for ${reclassifiedCount} keywords from localStorage`, 'info'); } } // Normalize summary field names for compatibility parsed.summary = normalizeSummaryFields(parsed.summary); // Store as fallback before checking Supabase localStorageFallback = parsed; // Check if localStorage data is missing search_volume (indicates stale data) const keywordsMissingVolume = parsed.combinedRows.filter(r => r.search_volume == null || r.search_volume === undefined).length; const hasMissingVolume = keywordsMissingVolume > 0; // Always check Supabase if localStorage has fewer than 10 keywords (likely stale data) if (parsed.combinedRows.length < 10) { debugLog(`⚠ LocalStorage has only ${parsed.combinedRows.length} keywords, checking Supabase for more...`, 'info'); // Continue to Supabase check below } else if (hasMissingVolume) { // Check Supabase if localStorage data is missing search_volume debugLog(`⚠ LocalStorage data is missing search_volume for ${keywordsMissingVolume}/${parsed.combinedRows.length} keywords, checking Supabase...`, 'info'); // Continue to Supabase check below } else { // Check if data is recent (within last 24 hours) - if older, check Supabase if (parsed.timestamp) { const localTime = new Date(parsed.timestamp); const now = new Date(); const hoursDiff = (now - localTime) / (1000 * 60 * 60); if (hoursDiff < 24) { return parsed; } else { debugLog(`⚠ LocalStorage data is ${Math.round(hoursDiff)} hours old, checking Supabase for newer data...`, 'info'); } } else { return parsed; } } } } catch (e) { debugLog('⚠ Invalid Ranking & AI data in localStorage', 'warn'); } } } // Try Supabase (either forced or as fallback) debugLog(`📊 Loading Ranking & AI data from Supabase for propertyUrl: ${propertyUrl}`, 'info'); const response = await fetch(apiUrl(`/api/supabase/get-latest-audit?propertyUrl=${encodeURIComponent(propertyUrl)}`)); debugLog(`📊 API fetch response status: ${response.status} ${response.statusText}`, 'info'); if (response.ok) { const json = await response.json(); debugLog(`📊 API response parsed: status=${json.status}`, 'info'); debugLog(`📊 Response has data object: ${!!json.data}`, 'info'); debugLog(`📊 Response has rankingAiData: ${!!json.data?.rankingAiData}`, 'info'); if (json.status === 'ok' && json.data) { if (json.data.rankingAiData) { if (json.data.rankingAiData.combinedRows) { const keywordCount = json.data.rankingAiData.combinedRows.length; const summaryKeys = json.data.rankingAiData.summary ? Object.keys(json.data.rankingAiData.summary).join(', ') : 'none'; debugLog(`✓ Ranking & AI data loaded from Supabase: ${keywordCount} keywords`, 'success'); debugLog(` Summary fields: ${summaryKeys}`, 'info'); debugLog(` First keyword sample: ${json.data.rankingAiData.combinedRows[0]?.keyword || 'none'}`, 'info'); // Compute opportunity scores if missing (for older data) const combinedRows = json.data.rankingAiData.combinedRows; // Re-classify pageType for all rows (fixes stale database values) // This ensures Event/Product pages are correctly identified even if DB has old values if (typeof window.classifyUrlForRankingAi === 'function') { let reclassifiedCount = 0; combinedRows.forEach(row => { const bestUrl = row.best_url || row.bestUrl || null; if (bestUrl) { const oldPageType = row.pageType || 'Landing'; const classification = window.classifyUrlForRankingAi(bestUrl, row.keyword || null); const newPageType = classification.pageType; if (newPageType !== oldPageType) { row.pageType = newPageType; // Also update segment if it changed if (classification.segment && classification.segment !== row.segment) { row.segment = classification.segment; } reclassifiedCount++; } } }); if (reclassifiedCount > 0) { debugLog(`✓ Re-classified pageType for ${reclassifiedCount} keywords (fixed stale DB values)`, 'info'); } } const needsOpportunityScore = combinedRows.some(r => r.opportunityScore == null); if (needsOpportunityScore) { debugLog(`⚠ Computing opportunity scores for ${keywordCount} keywords (missing from Supabase)`, 'info'); const maxDemandShare = combinedRows.reduce((max, r) => { const ds = r.demand_share ?? 0; return ds > max ? ds : max; }, 0); combinedRows.forEach(row => { if (row.opportunityScore == null) { const oppResult = computeKeywordOpportunityScore(row, maxDemandShare); row.opportunityScore = oppResult.score; row.oppDemandComponent = oppResult.demandComponent; row.oppRankComponent = oppResult.rankComponent; row.oppAiComponent = oppResult.aiComponent; } }); } // Normalize summary field names (convert snake_case to camelCase for compatibility) const normalizedSummary = normalizeSummaryFields(json.data.rankingAiData.summary); // Also save to localStorage for faster access next time localStorage.setItem('rankingAiData', JSON.stringify({ combinedRows: combinedRows, summary: normalizedSummary, timestamp: new Date().toISOString() })); debugLog(`✓ Saved ${keywordCount} keywords to localStorage`, 'success'); return { combinedRows, summary: normalizedSummary }; } else { debugLog('⚠ rankingAiData exists but missing combinedRows array', 'warn'); debugLog(` rankingAiData keys: ${Object.keys(json.data.rankingAiData || {}).join(', ')}`, 'warn'); } } else { debugLog('⚠ rankingAiData is null or undefined in Supabase response', 'warn'); debugLog(` Available data keys: ${Object.keys(json.data || {}).join(', ')}`, 'warn'); if (json.data.rankingAiData === null) { debugLog(' rankingAiData is explicitly null - no keyword rows found in database', 'warn'); } } } else { debugLog(`⚠ API returned error status: ${json.status}`, 'warn'); debugLog(` Error message: ${json.message || 'unknown'}`, 'warn'); if (json.details) { debugLog(` Error details: ${typeof json.details === 'string' ? json.details : JSON.stringify(json.details)}`, 'warn'); } } } else { const errorText = await response.text(); debugLog(`⚠ Failed to load from Supabase: HTTP ${response.status}`, 'error'); debugLog(` Error response: ${errorText.substring(0, 200)}`, 'error'); } // If Supabase check failed or returned no data, fall back to localStorage if available if (localStorageFallback) { debugLog(`✓ Falling back to localStorage data (${localStorageFallback.combinedRows.length} keywords)`, 'success'); return localStorageFallback; } } catch (err) { debugLog(`✗ Error loading Ranking & AI data from storage: ${err.message}`, 'error'); // If error occurred but we have localStorage fallback, return it if (localStorageFallback) { debugLog(`✓ Falling back to localStorage data after error (${localStorageFallback.combinedRows.length} keywords)`, 'success'); return localStorageFallback; } } return null; } // ====================== // Ranking & AI: Fetch and combine data // ====================== async function loadRankingAiData(force = false) { debugLog('📊 loadRankingAiData called with force=' + force, 'info'); // Get RankingAiModule from window (it should always be there) const mod = window.RankingAiModule; if (!mod) { debugLog('✗ RankingAiModule is not defined in window', 'error'); debugLog('✗ Available window properties: ' + Object.keys(window).filter(k => k.includes('Ranking') || k.includes('AI')).join(', '), 'error'); throw new Error('RankingAiModule is not defined. Please refresh the page.'); } debugLog('✓ RankingAiModule found', 'success'); debugLog('✓ RankingAiModule has TRACKED_KEYWORDS: ' + (mod.TRACKED_KEYWORDS ? mod.TRACKED_KEYWORDS.length + ' keywords' : 'missing'), 'success'); const { hasLoadedOnce, isLoading } = mod.state(); debugLog(`📊 State check: hasLoadedOnce=${hasLoadedOnce}, isLoading=${isLoading}, force=${force}`, 'info'); if (isLoading) { debugLog('⚠ Already loading, skipping', 'warn'); return; } // If not forcing, try to load from storage first // But if force=false and hasLoadedOnce=true, still check Supabase for newer data if (!force) { const storedData = await loadRankingAiDataFromStorage(hasLoadedOnce); debugLog(`📊 loadRankingAiDataFromStorage returned: ${storedData ? 'data found' : 'null'}`, 'info'); if (storedData) { debugLog(`📊 Stored data has combinedRows: ${!!storedData.combinedRows}, count: ${storedData.combinedRows?.length || 0}`, 'info'); debugLog(`📊 Stored data has summary: ${!!storedData.summary}`, 'info'); } if (storedData && storedData.combinedRows && storedData.summary) { debugLog(`✓ Setting data in RankingAiModule: ${storedData.combinedRows.length} keywords`, 'success'); // Normalize summary field names before setting const normalizedSummary = normalizeSummaryFields(storedData.summary); mod.setData(storedData.combinedRows, normalizedSummary); mod.setLoadedOnce(true); debugLog(`✓ Calling renderRankingAiTab()`, 'info'); renderRankingAiTab(); const lastRunEl = document.getElementById("ranking-ai-last-run"); if (lastRunEl && storedData.timestamp) { const date = new Date(storedData.timestamp); // Format in GMT/UTC: "DD MMM YYYY, HH:MM:SS GMT" const day = String(date.getUTCDate()).padStart(2, '0'); const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const month = monthNames[date.getUTCMonth()]; const year = date.getUTCFullYear(); const hours = String(date.getUTCHours()).padStart(2, '0'); const minutes = String(date.getUTCMinutes()).padStart(2, '0'); const seconds = String(date.getUTCSeconds()).padStart(2, '0'); const formatted = `${day} ${month} ${year}, ${hours}:${minutes}:${seconds} GMT`; lastRunEl.textContent = `Last run: ${formatted}`; debugLog(`✓ Updated last run timestamp: ${formatted}`, 'info'); } debugLog(`✓ Ranking & AI data loaded from storage (${storedData.combinedRows.length} keywords)`, 'success'); return; } else { debugLog(`⚠ Stored data incomplete or missing`, 'warn'); if (storedData) { debugLog(` Missing combinedRows: ${!storedData.combinedRows}`, 'warn'); debugLog(` Missing summary: ${!storedData.summary}`, 'warn'); } } } if (hasLoadedOnce && !force) { debugLog('⚠ Already loaded once and force=false, skipping', 'warn'); return; } debugLog('📊 Starting data fetch from APIs...', 'info'); mod.setLoading(true); const refreshBtn = document.getElementById("ranking-ai-refresh"); if (refreshBtn) { refreshBtn.disabled = true; refreshBtn.textContent = "Loading…"; } // Show progress modal RankingAiProgressModal.show(); RankingAiProgressModal.updateProgress(5, 0); // Initialize step RankingAiProgressModal.updateCounts(''); // Declare variables outside try block for catch block access let allSerpResults = []; let keywords = []; let scanAborted = false; // Flag to track if scan should be aborted // Set up abort handler const stopBtn = document.getElementById('rankingAiProgressStop'); if (stopBtn) { stopBtn.onclick = () => { scanAborted = true; stopBtn.disabled = true; stopBtn.textContent = 'Stopping...'; debugLog('⚠️ Scan abort requested by user', 'warn'); RankingAiProgressModal.updateCounts('⚠️ Scan abort requested...'); }; } try { // Load keywords from database instead of hardcoded list let keywordsFromDb = []; try { const keywordsResp = await fetch(apiUrl('/api/keywords/get')); if (keywordsResp.ok) { const keywordsData = await keywordsResp.json(); if (keywordsData.status === 'ok' && Array.isArray(keywordsData.keywords) && keywordsData.keywords.length > 0) { keywordsFromDb = keywordsData.keywords; debugLog(`✓ Loaded ${keywordsFromDb.length} keywords from database`, 'success'); } } } catch (err) { debugLog(`⚠️ Failed to load keywords from database: ${err.message}. Falling back to hardcoded list.`, 'warn'); } // Use database keywords if available, otherwise fall back to hardcoded list // Filter out empty strings and ensure all keywords are valid const rawKeywords = keywordsFromDb.length > 0 ? keywordsFromDb : mod.TRACKED_KEYWORDS; keywords = rawKeywords .filter(kw => kw && typeof kw === 'string' && kw.trim().length > 0) .map(kw => kw.trim()); const BATCH_SIZE = 20; // Match API limit // Show keyword count BEFORE starting scan const keywordCountEl = document.getElementById('rankingAiKeywordCount'); const keywordCountValueEl = document.getElementById('rankingAiKeywordCountValue'); if (keywordCountEl && keywordCountValueEl) { keywordCountValueEl.textContent = `${keywords.length} keyword${keywords.length !== 1 ? 's' : ''}`; keywordCountEl.style.display = 'block'; RankingAiProgressModal.updateCounts(`Ready to scan ${keywords.length} keywords. Click "Start Scan" to begin.`); } // Wait for user confirmation before starting (show count for 2 seconds, then auto-start) await new Promise(resolve => setTimeout(resolve, 2000)); // Check if aborted during wait if (scanAborted) { throw new Error('Scan aborted by user'); } // Hide keyword count and show stop button if (keywordCountEl) keywordCountEl.style.display = 'none'; if (stopBtn) { stopBtn.style.display = 'block'; stopBtn.disabled = false; } debugLog(`📊 Fetching ranking & AI data for ${keywords.length} keywords`, 'info'); debugLog(`📊 Keywords: ${keywords.slice(0, 5).join(', ')}${keywords.length > 5 ? '... (+' + (keywords.length - 5) + ' more)' : ''}`, 'info'); debugLog(`📊 SERP endpoint: ${mod.SERP_RANK_ENDPOINT}`, 'info'); debugLog(`📊 AI endpoint: ${mod.AI_MODE_ENDPOINT}`, 'info'); // Step 1: Fetch SERP Rankings (batched if needed) with incremental saving RankingAiProgressModal.updateProgress(15, 1); RankingAiProgressModal.updateCounts(`Processing ${keywords.length} keywords...`); RankingAiProgressModal.setActiveStep(1); debugLog('📊 Starting SERP fetch with incremental saving...', 'info'); const propertyUrl = localStorage.getItem('gsc_property_url') || localStorage.getItem('last_property_url') || ''; const auditDate = new Date().toISOString().split('T')[0]; // Batch keywords if needed allSerpResults = []; const batches = []; for (let i = 0; i < keywords.length; i += BATCH_SIZE) { batches.push(keywords.slice(i, i + BATCH_SIZE)); } debugLog(`📊 Processing ${batches.length} batch(es) of keywords`, 'info'); // Accumulate combined rows for final summary calculation let allCombinedRows = []; for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) { // Check if scan was aborted if (scanAborted) { throw new Error('Scan aborted by user'); } const batch = batches[batchIdx]; const queryParam = encodeURIComponent(batch.join(",")); const progressPercent = 15 + Math.floor((batchIdx / batches.length) * 35); RankingAiProgressModal.updateProgress(progressPercent, 1); RankingAiProgressModal.updateCounts(`Processing batch ${batchIdx + 1}/${batches.length} (${batch.length} keywords)...`); debugLog(`📊 Fetching batch ${batchIdx + 1}/${batches.length} (${batch.length} keywords)`, 'info'); try { const serpRes = await fetch(`${mod.SERP_RANK_ENDPOINT}?keywords=${queryParam}`); debugLog(`📊 SERP response status for batch ${batchIdx + 1}: ${serpRes.status}`, 'info'); if (!serpRes.ok) { const errorText = await serpRes.text(); debugLog(`✗ SERP request failed for batch ${batchIdx + 1}: ${serpRes.status} - ${errorText}`, 'error'); // If it's a timeout or server error, show warning but continue with partial results if (serpRes.status === 504 || serpRes.status === 500) { debugLog(`⚠️ Batch ${batchIdx + 1} timed out or errored, continuing with remaining batches...`, 'warn'); RankingAiProgressModal.updateCounts(`⚠️ Batch ${batchIdx + 1} failed (${serpRes.status}), continuing...`); // Add error results for this batch batch.forEach(kw => { allSerpResults.push({ keyword: kw, best_rank_group: null, best_rank_absolute: null, best_url: null, best_title: null, has_ai_overview: false, serp_features: { local_pack: false, featured_snippet: false, people_also_ask: false }, ai_overview_present_any: false, local_pack_present_any: false, paa_present_any: false, featured_snippet_present_any: false, search_volume: null, error: `Batch request failed: ${serpRes.status}` }); }); continue; } else { RankingAiProgressModal.updateCounts(`Error: ${serpRes.status}`); throw new Error(`SERP rank request failed: ${serpRes.status} - ${errorText}`); } } const serpJson = await serpRes.json(); const batchResults = serpJson.per_keyword || []; allSerpResults.push(...batchResults); debugLog(`✓ Batch ${batchIdx + 1} completed: ${batchResults.length} results`, 'success'); // Save this batch incrementally (without AI data for now - will update later) // Create minimal combined rows for this batch const batchCombinedRows = batchResults.map(row => { const bestUrl = row.best_url || null; let classification = { segment: "Education", pageType: "Landing" }; if (typeof window.classifyUrlForRankingAi === 'function') { classification = window.classifyUrlForRankingAi(bestUrl || '', row.keyword); } return { keyword: row.keyword, segment: classification.segment, pageType: classification.pageType, best_rank_group: row.best_rank_group, best_rank_absolute: row.best_rank_absolute, best_url: bestUrl, best_title: row.best_title || "", // Use SERP response's has_ai_overview (from DataForSEO) - AI fetch will add citation details later has_ai_overview: !!(row.has_ai_overview), ai_total_citations: 0, ai_alan_citations_count: 0, ai_alan_citations: [], ai_sample_citations: [], serp_features: row.serp_features || { has_ai_overview: false, has_local_pack: false, has_featured_snippet: false, has_people_also_ask: false }, // New boolean fields for SERP feature coverage (from SERP API response) ai_overview_present_any: row.ai_overview_present_any === true || row.has_ai_overview === true, local_pack_present_any: row.local_pack_present_any === true || (row.serp_features && row.serp_features.local_pack === true), paa_present_any: row.paa_present_any === true || (row.serp_features && row.serp_features.people_also_ask === true), featured_snippet_present_any: row.featured_snippet_present_any === true || (row.serp_features && row.serp_features.featured_snippet === true), competitor_counts: {}, search_volume: row.search_volume ?? null, demand_share: 0 // Will be recalculated after all batches }; }); // Save batch incrementally if (propertyUrl) { const saveResult = await saveRankingAiDataIncremental(batchCombinedRows, auditDate, propertyUrl); if (saveResult.success) { debugLog(`✓ Batch ${batchIdx + 1} saved incrementally: ${saveResult.saved} keywords`, 'success'); RankingAiProgressModal.updateCounts(`✓ Batch ${batchIdx + 1} saved (${saveResult.saved} keywords)`); } else { debugLog(`⚠ Batch ${batchIdx + 1} incremental save failed: ${saveResult.error}`, 'warn'); } } allCombinedRows.push(...batchCombinedRows); } catch (batchErr) { debugLog(`✗ Error processing batch ${batchIdx + 1}: ${batchErr.message}`, 'error'); // If it's a timeout, continue with remaining batches if (batchErr.message.includes('timeout') || batchErr.message.includes('504')) { debugLog(`⚠️ Batch ${batchIdx + 1} timed out, continuing with remaining batches...`, 'warn'); RankingAiProgressModal.updateCounts(`⚠️ Batch ${batchIdx + 1} timed out, continuing...`); // Add error results for this batch batch.forEach(kw => { allSerpResults.push({ keyword: kw, best_rank_group: null, best_rank_absolute: null, best_url: null, best_title: null, has_ai_overview: false, serp_features: { local_pack: false, featured_snippet: false, people_also_ask: false }, ai_overview_present_any: false, local_pack_present_any: false, paa_present_any: false, featured_snippet_present_any: false, search_volume: null, error: `Batch request timed out` }); }); continue; } else { throw batchErr; } } } RankingAiProgressModal.updateProgress(50, 1); RankingAiProgressModal.updateCounts(`✓ Retrieved SERP data for ${allSerpResults.length}/${keywords.length} keywords (saved incrementally)`); // Step 2: Fetch AI Overview Data (batched to avoid server timeout) RankingAiProgressModal.updateProgress(55, 2); RankingAiProgressModal.setActiveStep(2); RankingAiProgressModal.updateCounts(`Checking AI Overview presence and citations...`); debugLog('📊 Starting AI fetch (batched)...', 'info'); let aiRows = []; let aiFetchError = null; try { // Batch AI requests to avoid Vercel 300s timeout // Process 10 keywords per batch (each API call takes ~5s, so 10 keywords = ~50s per batch) const AI_BATCH_SIZE = 10; const aiBatches = []; for (let i = 0; i < keywords.length; i += AI_BATCH_SIZE) { aiBatches.push(keywords.slice(i, i + AI_BATCH_SIZE)); } debugLog(`📊 Processing AI data in ${aiBatches.length} batches of up to ${AI_BATCH_SIZE} keywords each`, 'info'); // Process batches sequentially to avoid overwhelming the API for (let batchIdx = 0; batchIdx < aiBatches.length; batchIdx++) { // Check if scan was aborted if (scanAborted) { throw new Error('Scan aborted by user'); } const batch = aiBatches[batchIdx]; const batchProgress = 55 + Math.floor((batchIdx / aiBatches.length) * 20); // 55-75% progress RankingAiProgressModal.updateProgress(batchProgress, 2); RankingAiProgressModal.updateCounts(`Fetching AI data: batch ${batchIdx + 1}/${aiBatches.length} (${batch.length} keywords)...`); debugLog(`📊 AI batch ${batchIdx + 1}/${aiBatches.length}: ${batch.length} keywords`, 'info'); try { // Timeout per batch: 90 seconds (should be enough for 10 keywords) const aiFetchPromise = fetch(mod.AI_MODE_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ queries: batch }) }); // Increase timeout to 300 seconds (5 minutes) to match Vercel serverless function timeout const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`AI batch ${batchIdx + 1} timeout after 300 seconds`)), 300000) ); const aiRes = await Promise.race([aiFetchPromise, timeoutPromise]); debugLog(`📊 AI batch ${batchIdx + 1} response status: ${aiRes.status}`, 'info'); if (!aiRes.ok) { const errorText = await aiRes.text(); debugLog(`✗ AI batch ${batchIdx + 1} failed: ${aiRes.status} - ${errorText}`, 'error'); // Add empty results for this batch batch.forEach(keyword => { aiRows.push({ query: keyword, has_ai_overview: false, total_citations: 0, alanranger_citations_count: 0, alanranger_citations: [], sample_citations: [], error: `Batch request failed: ${aiRes.status}` }); }); } else { const aiJson = await aiRes.json(); const batchResults = aiJson.per_query || []; aiRows.push(...batchResults); debugLog(`✓ AI batch ${batchIdx + 1}: Retrieved ${batchResults.length} results`, 'success'); } } catch (batchErr) { debugLog(`✗ AI batch ${batchIdx + 1} error: ${batchErr.message}`, 'error'); // Add empty results for this batch batch.forEach(keyword => { aiRows.push({ query: keyword, has_ai_overview: false, total_citations: 0, alanranger_citations_count: 0, alanranger_citations: [], sample_citations: [], error: batchErr.message }); }); } // Small delay between batches to avoid rate limiting if (batchIdx < aiBatches.length - 1) { await new Promise(resolve => setTimeout(resolve, 500)); } } RankingAiProgressModal.updateProgress(75, 2); RankingAiProgressModal.updateCounts(`✓ Retrieved AI Overview data for ${aiRows.length} keywords`); debugLog(`✓ Retrieved ${aiRows.length} AI results total`, 'success'); } catch (aiErr) { debugLog(`✗ AI fetch error: ${aiErr.message}`, 'error'); RankingAiProgressModal.updateCounts(`⚠ AI Overview check failed (${aiErr.message}), continuing with SERP data only...`); aiFetchError = aiErr.message; // Continue with empty AI data - UI will still show SERP results } // Step 3: Process Results and merge AI data RankingAiProgressModal.updateProgress(80, 3); RankingAiProgressModal.setActiveStep(3); RankingAiProgressModal.updateCounts(`Combining SERP and AI data...`); debugLog('📊 Parsing JSON responses...', 'info'); debugLog(`📊 SERP results: ${allSerpResults.length} keywords processed`, 'info'); if (aiFetchError) { debugLog(`⚠ AI data unavailable: ${aiFetchError}`, 'warn'); } const serpRows = allSerpResults; // Use batched results debugLog(`Received ${serpRows.length} SERP results and ${aiRows.length} AI results`, 'info'); RankingAiProgressModal.updateCounts(`Processing ${serpRows.length} SERP results and ${aiRows.length} AI results...`); // Map AI rows by normalised query const aiByKeyword = {}; aiRows.forEach(row => { const key = RankingAiModule.normaliseKeyword(row.query); aiByKeyword[key] = row; }); // Update existing combined rows with AI data, or create new ones if needed // First, create a map of existing combined rows by keyword const combinedByKeyword = {}; allCombinedRows.forEach(row => { const key = RankingAiModule.normaliseKeyword(row.keyword); combinedByKeyword[key] = row; }); // First pass: classify all SERP rows and collect search volumes for demand_share calculation const rowsWithClassification = serpRows.map(row => { const key = RankingAiModule.normaliseKeyword(row.keyword); const aiRow = aiByKeyword[key] || null; const bestUrl = row.best_url || null; // Use intent-based classification (keyword text drives segment, not URL) const segment = RankingAiModule.classifyKeywordSegment(row.keyword, bestUrl); const pageType = RankingAiModule.classifyPageTypeForKeyword ? RankingAiModule.classifyPageTypeForKeyword(bestUrl) : "Landing"; const classification = { segment, pageType }; return { ...row, aiRow, classification, search_volume: row.search_volume ?? null }; }); // Calculate total demand for demand_share const totalDemand = rowsWithClassification.reduce((sum, r) => { const vol = r.search_volume; return sum + (vol && vol > 0 ? vol : 0); }, 0); // Second pass: build combined rows with demand_share and AI data const combined = rowsWithClassification.map(row => { const key = RankingAiModule.normaliseKeyword(row.keyword); const aiRow = row.aiRow || null; const bestUrl = row.best_url || null; const { segment, pageType } = row.classification; const aiCitations = aiRow?.alanranger_citations || []; const aiOtherCitations = (aiRow?.sample_citations || []).filter(c => { const domain = (c.domain || "").toLowerCase(); return domain && !domain.includes("alanranger.com"); }); // Count competitor domains const competitorCounts = {}; aiOtherCitations.forEach(c => { const d = (c.domain || "").toLowerCase(); if (!d) return; competitorCounts[d] = (competitorCounts[d] || 0) + 1; }); // Calculate demand_share const searchVolume = row.search_volume; const demandShare = (searchVolume && searchVolume > 0 && totalDemand > 0) ? searchVolume / totalDemand : 0; // Debug: Log search volume for first few keywords if (serpRows.indexOf(row) < 3) { debugLog(`[DEBUG] Keyword "${row.keyword}": search_volume=${searchVolume}, demand_share=${(demandShare * 100).toFixed(1)}%, segment=${segment}, pageType=${pageType}`, 'info'); } // Canonicalize URL - store both raw and canonical const rawTargetUrl = bestUrl || ""; const canonicalTargetUrl = canonicalizeUrl(rawTargetUrl); return { keyword: row.keyword, segment: segment, // Keep capitalized: Brand/Money/Education/Other pageType, best_rank_group: row.best_rank_group, best_rank_absolute: row.best_rank_absolute, best_url: bestUrl, // Keep raw for backward compatibility rawTargetUrl: rawTargetUrl, // Explicit raw URL targetUrl: canonicalTargetUrl, // Canonical URL (single source of truth) best_title: row.best_title || "", // Use SERP response's has_ai_overview (from DataForSEO) as primary source, // fallback to AI API response if SERP doesn't have it has_ai_overview: !!(row.has_ai_overview || (aiRow && aiRow.has_ai_overview)), ai_total_citations: aiRow?.total_citations ?? 0, ai_alan_citations_count: aiRow?.alanranger_citations_count ?? 0, ai_alan_citations: aiCitations, ai_sample_citations: aiOtherCitations, serp_features: row.serp_features || { has_ai_overview: false, has_local_pack: false, has_featured_snippet: false, has_people_also_ask: false }, // New boolean fields for SERP feature coverage (from SERP API response) ai_overview_present_any: row.ai_overview_present_any === true || row.has_ai_overview === true, local_pack_present_any: row.local_pack_present_any === true || (row.serp_features && row.serp_features.local_pack === true), paa_present_any: row.paa_present_any === true || (row.serp_features && row.serp_features.people_also_ask === true), featured_snippet_present_any: row.featured_snippet_present_any === true || (row.serp_features && row.serp_features.featured_snippet === true), competitor_counts: competitorCounts, search_volume: searchVolume, search_volume_trend: row.search_volume_trend || undefined, demand_share: demandShare // opportunityScore will be added after all rows are created (see below) }; }); // Compute maxDemandShare for opportunity score calculation const maxDemandShare = combined.reduce((max, r) => { const ds = r.demand_share ?? 0; return ds > max ? ds : max; }, 0); // Add opportunity score to each row combined.forEach(row => { const oppResult = computeKeywordOpportunityScore(row, maxDemandShare); row.opportunityScore = oppResult.score; row.oppDemandComponent = oppResult.demandComponent; row.oppRankComponent = oppResult.rankComponent; row.oppAiComponent = oppResult.aiComponent; }); // Debug: Log opportunity scores for first few keywords if (combined.length > 0) { const sampleRows = combined.slice(0, 3); sampleRows.forEach(r => { console.debug(`[Opportunity Score] "${r.keyword}": score=${r.opportunityScore}, D=${(r.oppDemandComponent * 100).toFixed(0)}%, R=${(r.oppRankComponent * 100).toFixed(0)}%, A=${(r.oppAiComponent * 100).toFixed(0)}%`); }); } // Summary metrics const totalKeywords = combined.length; const withRank = combined.filter(r => r.best_rank_group != null).length; const withAiOverview = combined.filter(r => r.has_ai_overview).length; const withAiCitation = combined.filter(r => r.ai_alan_citations_count > 0).length; const top10 = combined.filter(r => r.best_rank_group != null && r.best_rank_group <= 10).length; const top3 = combined.filter(r => r.best_rank_group != null && r.best_rank_group <= 3).length; // Count keywords with search volume (including 0, but not null/undefined) const withSearchVolume = combined.filter(r => r.search_volume != null && r.search_volume !== undefined).length; // Log search volume for each keyword combined.forEach(row => { const volume = row.search_volume != null && row.search_volume !== undefined ? row.search_volume.toLocaleString() : 'none'; debugLog(`Search volume for "${row.keyword}": ${volume}`, 'info'); }); debugLog(`Search volume summary: ${withSearchVolume}/${totalKeywords} keywords have search volume data`, 'info'); RankingAiProgressModal.updateProgress(90, 3); RankingAiProgressModal.updateCounts(`✓ Processed ${totalKeywords} keywords (${withRank} ranked, ${withAiOverview} with AI Overview, ${withSearchVolume} with search volume)`); // Calculate visibility metrics from combined results (Ranking API only - not part of AIO pillars) const validRankingRows = combined.filter(r => r.best_rank_group != null && typeof r.best_rank_group === 'number'); let avgPositionUnweighted = null; let avgPositionVolumeWeighted = null; if (validRankingRows.length >= 1) { // Unweighted average position const sumRanks = validRankingRows.reduce((sum, k) => sum + k.best_rank_group, 0); avgPositionUnweighted = Math.round((sumRanks / validRankingRows.length) * 100) / 100; // Demand-weighted average position let sumWeightedRanks = 0; let sumVolumes = 0; for (const row of validRankingRows) { const vol = (row.search_volume !== null && row.search_volume !== undefined && row.search_volume > 0) ? row.search_volume : 10; // Fallback sumWeightedRanks += row.best_rank_group * vol; sumVolumes += vol; } if (sumVolumes > 0) { avgPositionVolumeWeighted = Math.round((sumWeightedRanks / sumVolumes) * 100) / 100; } } const summary = { total_keywords: totalKeywords, keywords_with_rank: withRank, keywords_with_ai_overview: withAiOverview, keywords_with_ai_citations: withAiCitation, top10, top3, // Include visibility metrics (Ranking API only - not part of AIO pillars) avg_position_unweighted: avgPositionUnweighted, avg_position_volume_weighted: avgPositionVolumeWeighted, keywords_used_for_avg: validRankingRows.length, keywords_with_volume: withSearchVolume }; // Debug: Log AI citations for first few keywords const keywordsWithCitations = combined.filter(r => r.ai_alan_citations_count > 0); debugLog(`📊 AI Citations Summary: ${keywordsWithCitations.length}/${totalKeywords} keywords have citations`, 'info'); if (keywordsWithCitations.length > 0) { keywordsWithCitations.slice(0, 5).forEach(row => { debugLog(` ✓ "${row.keyword}": ${row.ai_alan_citations_count} citations`, 'info'); }); } else { debugLog(` ⚠ No keywords have AI citations. AI fetch error: ${aiFetchError || 'none'}`, 'warn'); if (aiRows.length > 0) { debugLog(` 📊 AI rows received: ${aiRows.length}, checking first few...`, 'info'); aiRows.slice(0, 3).forEach(aiRow => { debugLog(` - "${aiRow.query}": alanranger_citations_count=${aiRow.alanranger_citations_count ?? 'undefined'}, total_citations=${aiRow.total_citations ?? 'undefined'}`, 'info'); }); } } // Normalize summary field names before setting (ensure camelCase for display) const normalizedSummary = normalizeSummaryFields(summary); mod.setData(combined, normalizedSummary); mod.setLoadedOnce(true); // Step 4: Save Data RankingAiProgressModal.updateProgress(95, 4); RankingAiProgressModal.setActiveStep(4); RankingAiProgressModal.updateCounts(`Saving to database...`); // Update incrementally saved rows with merged AI data if (propertyUrl && combined.length > 0) { debugLog(`📊 Updating incrementally saved rows with merged AI data...`, 'info'); const updateResult = await saveRankingAiDataIncremental(combined, auditDate, propertyUrl); if (updateResult.success) { debugLog(`✓ Updated ${updateResult.saved} keyword rows with AI data`, 'success'); } else { debugLog(`⚠ Failed to update rows with AI data: ${updateResult.error}`, 'warn'); } } // Save to Supabase and localStorage await saveRankingAiData(combined, summary); // CRITICAL: Fetch and save queryTotals for all ranking keywords RankingAiProgressModal.updateProgress(96, 4); RankingAiProgressModal.updateCounts(`Fetching GSC query totals for ${combined.length} keywords...`); debugLog(`📊 Fetching queryTotals for ${combined.length} ranking keywords...`, 'info'); try { const allKeywords = combined.map(r => r.keyword).filter(k => k && k.trim()); if (allKeywords.length > 0) { const propertyUrl = localStorage.getItem('gsc_property_url') || localStorage.getItem('last_property_url') || ''; const dateRange = parseInt(localStorage.getItem('gsc_date_range') || '28'); // Fetch queryTotals from GSC API const keywordsParam = encodeURIComponent(JSON.stringify(allKeywords)); const propertyParam = encodeURIComponent(propertyUrl); const gscResponse = await fetch(apiUrl(`/api/aigeo/gsc-entity-metrics?property=${propertyParam}&keywords=${keywordsParam}&days=${dateRange}`)); if (gscResponse.ok) { const gscData = await gscResponse.json(); if (gscData.status === 'ok' && gscData.data && Array.isArray(gscData.data.queryTotals)) { const queryTotals = gscData.data.queryTotals; debugLog(`✓ Fetched queryTotals for ${queryTotals.length} keywords from GSC`, 'success'); // Load existing audit data const savedAudit = loadAuditResultsSync(); if (savedAudit && savedAudit.searchData) { // Merge queryTotals into searchData savedAudit.searchData.queryTotals = queryTotals; // CRITICAL: Use the SAME audit_date as the existing audit, not today's date // This ensures queryTotals are saved to the same audit record let auditDate = new Date().toISOString().split('T')[0]; // Fallback to today if (savedAudit.timestamp) { try { auditDate = new Date(savedAudit.timestamp).toISOString().split('T')[0]; debugLog(`📊 Using existing audit date for queryTotals: ${auditDate}`, 'info'); } catch (e) { debugLog(`⚠ Failed to parse saved audit timestamp, using today's date`, 'warn'); } } // Save to Supabase const saveResponse = await fetch(apiUrl('/api/supabase/save-audit'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ propertyUrl: propertyUrl, auditDate: auditDate, searchData: savedAudit.searchData // Include updated searchData with queryTotals }) }); if (saveResponse.ok) { debugLog(`✓ Saved queryTotals to Supabase (${queryTotals.length} keywords) for audit_date: ${auditDate}`, 'success'); // CRITICAL: Also update localStorage so loadAuditResultsSync() can find the data try { safeSetLocalStorage('last_audit_results', savedAudit); debugLog(`✓ Updated localStorage with queryTotals`, 'success'); } catch (localStorageErr) { debugLog(`⚠ Failed to update localStorage: ${localStorageErr.message}`, 'warn'); } // Update audit date pill to reflect new scan updateAuditTimestamp(new Date().toISOString()); } else { const errorText = await saveResponse.text(); debugLog(`⚠ Failed to save queryTotals to Supabase: ${saveResponse.status} - ${errorText}`, 'warn'); } } else { debugLog(`⚠ No existing audit data found, creating new audit record with queryTotals...`, 'warn'); // Create new audit record with queryTotals const auditDate = new Date().toISOString().split('T')[0]; const saveResponse = await fetch(apiUrl('/api/supabase/save-audit'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ propertyUrl: propertyUrl, auditDate: auditDate, searchData: { queryTotals: queryTotals } }) }); if (saveResponse.ok) { debugLog(`✓ Created new audit record with queryTotals (${queryTotals.length} keywords)`, 'success'); // CRITICAL: Also save to localStorage so loadAuditResultsSync() can find the data try { const newAuditData = { searchData: { queryTotals: queryTotals }, timestamp: new Date().toISOString(), propertyUrl: propertyUrl }; safeSetLocalStorage('last_audit_results', newAuditData); debugLog(`✓ Saved new audit record to localStorage with queryTotals`, 'success'); } catch (localStorageErr) { debugLog(`⚠ Failed to save to localStorage: ${localStorageErr.message}`, 'warn'); } updateAuditTimestamp(new Date().toISOString()); } else { const errorText = await saveResponse.text(); debugLog(`⚠ Failed to create audit record with queryTotals: ${saveResponse.status} - ${errorText}`, 'warn'); } } } else { debugLog(`⚠ GSC API did not return queryTotals data`, 'warn'); } } else { const errorText = await gscResponse.text(); debugLog(`⚠ Failed to fetch queryTotals from GSC: ${gscResponse.status} - ${errorText}`, 'warn'); } } else { debugLog(`⚠ No keywords to fetch queryTotals for`, 'warn'); } } catch (queryTotalsErr) { debugLog(`✗ Error fetching/saving queryTotals: ${queryTotalsErr.message}`, 'error'); // Don't fail the entire scan if queryTotals fetch fails } RankingAiProgressModal.updateProgress(98, 4); RankingAiProgressModal.updateCounts(`✓ Data saved successfully`); // Step 5: Complete RankingAiProgressModal.updateProgress(100, 5); RankingAiProgressModal.setActiveStep(5); // Show completion summary RankingAiProgressModal.showSummary({ totalKeywords, keywordsWithRank: withRank, top10, top3, keywordsWithAiOverview: withAiOverview, keywordsWithAiCitations: withAiCitation, avgPositionUnweighted: summary.avg_position_unweighted, avgPositionVolumeWeighted: summary.avg_position_volume_weighted, keywordsWithVolume: summary.keywords_with_volume, aiFetchError: aiFetchError || null }); RankingAiProgressModal.updateCounts(`✓ Scan completed successfully!`); renderRankingAiTab(); const lastRunEl = document.getElementById("ranking-ai-last-run"); if (lastRunEl) { const now = new Date(); // Format in GMT/UTC: "DD MMM YYYY, HH:MM:SS GMT" const day = String(now.getUTCDate()).padStart(2, '0'); const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const month = monthNames[now.getUTCMonth()]; const year = now.getUTCFullYear(); const hours = String(now.getUTCHours()).padStart(2, '0'); const minutes = String(now.getUTCMinutes()).padStart(2, '0'); const seconds = String(now.getUTCSeconds()).padStart(2, '0'); const formatted = `${day} ${month} ${year}, ${hours}:${minutes}:${seconds} GMT`; lastRunEl.textContent = `Last run: ${formatted}`; } debugLog(`✓ Ranking & AI data loaded: ${totalKeywords} keywords, ${withRank} with ranks, ${withAiOverview} with AI Overview`, 'success'); // Don't hide modal - keep it visible with summary } catch (err) { console.error("Ranking & AI load error", err); const errorMsg = scanAborted ? 'Scan aborted by user' : err.message; debugLog(`✗ Ranking & AI load error: ${errorMsg}`, 'error'); // If aborted, show specific message if (scanAborted) { RankingAiProgressModal.updateCounts('⚠️ Scan aborted by user'); RankingAiProgressModal.showSummary({ error: true, errorMessage: 'Scan was aborted by user. No data was saved.', totalKeywords: keywords?.length || 0 }); return; } // Try to load any saved data from Supabase (SERP data may have been saved incrementally) debugLog('📊 Attempting to load saved data from Supabase after error...', 'info'); try { const savedData = await loadRankingAiDataFromStorage(true); // Force Supabase check if (savedData && savedData.combinedRows && savedData.combinedRows.length > 0) { debugLog(`✓ Loaded ${savedData.combinedRows.length} keywords from Supabase after error`, 'success'); const normalizedSummary = normalizeSummaryFields(savedData.summary); mod.setData(savedData.combinedRows, normalizedSummary); mod.setLoadedOnce(true); renderRankingAiTab(); RankingAiProgressModal.updateProgress(100, 5); RankingAiProgressModal.setActiveStep(5); RankingAiProgressModal.showSummary({ error: true, errorMessage: `Scan encountered an error, but loaded ${savedData.combinedRows.length} saved keywords from database: ${err.message}`, totalKeywords: savedData.combinedRows.length, keywordsWithRank: savedData.summary?.keywords_with_rank || 0, top10: savedData.summary?.top10 || 0, top3: savedData.summary?.top3 || 0, keywordsWithAiOverview: savedData.summary?.keywords_with_ai_overview || 0, keywordsWithAiCitations: savedData.summary?.keywords_with_ai_citations || 0 }); RankingAiProgressModal.updateCounts(`⚠ Error occurred, but loaded saved data from database`); return; // Exit early since we loaded saved data } } catch (loadErr) { debugLog(`✗ Failed to load saved data: ${loadErr.message}`, 'error'); } const lastRunEl = document.getElementById("ranking-ai-last-run"); if (lastRunEl) { lastRunEl.textContent = "Error loading ranking & AI data. See console for details."; } // Show error summary in modal instead of hiding RankingAiProgressModal.updateProgress(100, 5); RankingAiProgressModal.setActiveStep(5); RankingAiProgressModal.showSummary({ error: true, errorMessage: err.message, totalKeywords: keywords?.length || 0, keywordsWithRank: allSerpResults?.filter(r => r.best_rank_group != null).length || 0 }); RankingAiProgressModal.updateCounts(`✗ Error: ${err.message}`); } finally { RankingAiModule.setLoading(false); const refreshBtn = document.getElementById("ranking-ai-refresh"); if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = "Run ranking & AI check"; } // Enable close button const closeBtn = document.getElementById('rankingAiProgressClose'); if (closeBtn) { closeBtn.disabled = false; closeBtn.style.opacity = '1'; closeBtn.onclick = () => RankingAiProgressModal.hide(); } } } // Make loadRankingAiData globally available window.loadRankingAiData = loadRankingAiData; // ====================== // Ranking & AI: Rendering functions // ====================== // Filter and sort state let rankingFilterState = { segment: 'all', rank: 'all', volume: 'all', ctr: 'all', opportunity: 'all', aiOverview: 'all', aiCitation: 'all', pageType: 'all', serpFeatures: 'all', optimisationStatus: 'all', keyword: '', minOpportunity: null // number | null }; let rankingSortState = { column: 'opportunityScore', // Default sort by opportunity score (descending) direction: 'desc' }; let rankingPaginationState = { currentPage: 1, rowsPerPage: 10 // Default to 10 rows so all show without scrolling }; let rankingPriorityFilter = null; // { impact: 'high'|'medium'|'low', difficulty: 'high'|'medium'|'low' } | null let selectedKeywordId = null; // Store selected keyword identifier (keyword + url combination) let activePreset = null; // Track which preset is currently active ('all', 'high-impact-money', etc.) // Impact and Difficulty bucket thresholds (constants for tuning) const IMPACT_THRESHOLDS = { HIGH: 0.20, // >= 20% of tracked demand MEDIUM: 0.10 // 10-19.99% of tracked demand }; const DIFFICULTY_THRESHOLDS = { LOW: { min: 4, max: 15 }, // Easy win: rank 4-15 MEDIUM: { min: 16, max: 30 } // Medium: rank 16-30 }; /** * Calculate impact bucket for a keyword based on demand_share * @param {number} demandShare - Fraction (0-1) of total demand * @returns {'high'|'medium'|'low'} */ /** * Compute Keyword Opportunity Score (0-100) with component breakdown * @param {Object} row - Keyword row with demand_share, best_rank_group, has_ai_overview, etc. * @param {number} maxDemandShare - Maximum demand_share across all keywords (0-1) * @returns {Object} { score: 0-100, demandComponent: 0-1, rankComponent: 0-1, aiComponent: 0-1 } */ function computeKeywordOpportunityScore(row, maxDemandShare) { // 2.1 Demand component D (0-1) const demandShare = (row.demand_share ?? 0) * 100; // Convert to 0-100 if stored as 0-1 const maxShare = (maxDemandShare ?? 0) * 100; // Convert to 0-100 if stored as 0-1 let D = 0; if (maxShare > 0) { D = demandShare / maxShare; // relative to biggest keyword } D = Math.min(Math.max(D, 0), 1); // 2.2 Rank component R (0-1) const rank = row.best_rank_group ?? row.best_rank_absolute ?? null; let R; if (rank == null) { R = 0.2; // effectively not ranked } else if (rank <= 3) { R = 0.2; // already very strong; little upside } else if (rank <= 10) { R = 1.0; // page 1 but not top 3: sweet spot } else if (rank <= 20) { R = 0.8; // page 2 } else if (rank <= 50) { R = 0.5; // visible but weaker } else { R = 0.2; // very weak } // 2.3 AI leverage component A (0-1) const hasAi = row.has_ai_overview === true; const total = row.ai_total_citations ?? row.ai_citations_total ?? 0; const ours = row.ai_alan_citations_count ?? row.ai_citations_from_alan ?? 0; let A; if (!hasAi) { A = 0.5; // neutral: AI not helping or hurting yet } else if (!total) { A = 0.5; // overview present, but no citation info } else { const share = ours / total; // 0-1 if (share < 0.33) { A = 1.0; // overview exists, you're under-represented } else if (share <= 0.66) { A = 0.7; // present but not owning it } else { A = 0.4; // you already dominate AI answers } } // 2.4 Combined score S (0-100) const opportunity0to1 = 0.5 * D + // demand is main driver 0.3 * R + // room to move in classic rank 0.2 * A; // AI leverage const keywordOpportunityScore = Math.round(opportunity0to1 * 100); return { score: keywordOpportunityScore, demandComponent: D, rankComponent: R, aiComponent: A }; } function calculateImpactBucket(demandShare) { if (demandShare >= IMPACT_THRESHOLDS.HIGH) return 'high'; if (demandShare >= IMPACT_THRESHOLDS.MEDIUM) return 'medium'; return 'low'; } /** * Calculate difficulty bucket for a keyword based on best_rank_group * @param {number|null} bestRankGroup - Best rank group (position) * @returns {'low'|'medium'|'high'} */ function calculateDifficultyBucket(bestRankGroup) { // If already ranking in top 3, treat as low difficulty (already winning) if (bestRankGroup != null && bestRankGroup >= 1 && bestRankGroup <= 3) { return 'low'; } // Low difficulty: rank 4-15 (easy win) if (bestRankGroup != null && bestRankGroup >= DIFFICULTY_THRESHOLDS.LOW.min && bestRankGroup <= DIFFICULTY_THRESHOLDS.LOW.max) { return 'low'; } // Medium difficulty: rank 16-30 if (bestRankGroup != null && bestRankGroup >= DIFFICULTY_THRESHOLDS.MEDIUM.min && bestRankGroup <= DIFFICULTY_THRESHOLDS.MEDIUM.max) { return 'medium'; } // High difficulty: rank > 30 or null (no rank) return 'high'; } /** * Build comprehensive scorecard data for a keyword row * @param {Object} row - Keyword row from combinedRows * @returns {Object} Enriched scorecard data */ function buildKeywordScorecardData(row) { if (!row) return null; // Demand level from search_volume const searchVolume = row.search_volume; let demandLevel = 'Low'; if (searchVolume != null && searchVolume >= 500) { demandLevel = 'High'; } else if (searchVolume != null && searchVolume >= 200) { demandLevel = 'Medium'; } // Rank bucket const rank = row.best_rank_group; let rankBucket = 'page2plus'; let rankBucketLabel = 'beyond page 2'; let positionStrength = 'Weak'; if (rank != null) { if (rank >= 1 && rank <= 3) { rankBucket = 'top3'; rankBucketLabel = 'page 1'; positionStrength = 'Strong'; } else if (rank >= 4 && rank <= 10) { rankBucket = 'top10'; rankBucketLabel = 'page 1'; positionStrength = 'OK'; } else if (rank > 10) { rankBucket = 'page2plus'; rankBucketLabel = 'beyond page 2'; positionStrength = 'Weak'; } } // AI status bucket const hasAiOverview = row.has_ai_overview || false; const aiCitationsOurs = row.ai_alan_citations_count || 0; const aiCitationsTotal = row.ai_total_citations || 0; let aiStatus = 'no_ai'; let aiStatusLabel = 'No AI Overview'; if (hasAiOverview) { if (aiCitationsOurs === 0) { aiStatus = 'ai_no_citation'; aiStatusLabel = 'AI Overview present, not cited'; } else { const ourShare = aiCitationsTotal > 0 ? (aiCitationsOurs / aiCitationsTotal) : 0; if (ourShare >= 0.25) { aiStatus = 'ai_cited_strong'; aiStatusLabel = `AI Overview present, cited in ${aiCitationsOurs}/${aiCitationsTotal} citations (strong)`; } else { aiStatus = 'ai_cited_light'; aiStatusLabel = `AI Overview present, cited in ${aiCitationsOurs}/${aiCitationsTotal} citations (light)`; } } } // Impact, Difficulty, and Priority from opportunity score components // Use opportunity score components if available, otherwise fall back to old logic let impact, difficulty, priorityLevel; if (row.opportunityScore != null && row.oppDemandComponent != null && row.oppRankComponent != null && row.oppAiComponent != null) { // Use opportunity score components (new logic) // Impact raw: 0.7 * D + 0.3 * A const impactRaw = 0.7 * row.oppDemandComponent + 0.3 * row.oppAiComponent; if (impactRaw >= 0.66) impact = 'high'; else if (impactRaw >= 0.33) impact = 'medium'; else impact = 'low'; // Difficulty raw: 1 - R (harder if there is less room to move) const difficultyRaw = 1 - row.oppRankComponent; if (difficultyRaw <= 0.33) difficulty = 'low'; else if (difficultyRaw <= 0.66) difficulty = 'medium'; else difficulty = 'high'; // Priority from numeric opportunity score if (row.opportunityScore >= 70) priorityLevel = 'High'; else if (row.opportunityScore >= 40) priorityLevel = 'Medium'; else priorityLevel = 'Low'; } else { // Fallback to old logic if opportunity score not available impact = calculateImpactBucket(row.demand_share || 0); difficulty = calculateDifficultyBucket(rank); // Priority level from Impact + Difficulty priorityLevel = 'Low'; if (impact === 'high' && (difficulty === 'low' || difficulty === 'medium')) { priorityLevel = 'High'; } else if (impact === 'high' && difficulty === 'high' || impact === 'medium' && (difficulty === 'low' || difficulty === 'medium')) { priorityLevel = 'Medium'; } } return { // Raw data keyword: row.keyword, segment: row.segment, best_rank_group: rank, search_volume: searchVolume, demand_share: row.demand_share || 0, has_ai_overview: hasAiOverview, ai_citations_total: aiCitationsTotal, ai_citations_ours: aiCitationsOurs, ai_alan_citations: row.ai_alan_citations || [], // Store citations for display serp_features: row.serp_features || {}, // SERP feature boolean fields ai_overview_present_any: row.ai_overview_present_any === true || row.has_ai_overview === true, local_pack_present_any: row.local_pack_present_any === true || (row.serp_features && row.serp_features.local_pack === true), paa_present_any: row.paa_present_any === true || (row.serp_features && row.serp_features.people_also_ask === true), featured_snippet_present_any: row.featured_snippet_present_any === true || (row.serp_features && row.serp_features.featured_snippet === true), // Canonicalize URL - store both raw and canonical rawTargetUrl: row.rawTargetUrl || row.best_url || '', targetUrl: row.targetUrl || (row.best_url ? canonicalizeUrl(row.best_url) : ''), ranking_url: row.targetUrl || (row.best_url ? canonicalizeUrl(row.best_url) : ''), // Use canonical for ranking_url page_type: row.pageType || 'Landing', // Derived fields demand_level: demandLevel, rank_bucket: rankBucket, rank_bucket_label: rankBucketLabel, position_strength: positionStrength, ai_status: aiStatus, ai_status_label: aiStatusLabel, impact_bucket: impact, difficulty_bucket: difficulty, priority_level: priorityLevel, // Opportunity score fields opportunity_score: row.opportunityScore ?? null, opp_demand_component: row.oppDemandComponent ?? null, opp_rank_component: row.oppRankComponent ?? null, opp_ai_component: row.oppAiComponent ?? null }; } /** * Generate summary sentence for keyword based on demand, rank, and AI status * @param {Object} scorecardData - Data from buildKeywordScorecardData * @returns {string} Summary sentence */ function generateKeywordSummary(scorecardData) { const { demand_level, best_rank_group, has_ai_overview, ai_citations_ours, segment } = scorecardData; const parts = []; // Build summary sentence based on demand, rank, and AI status // Format: "[Demand level]-demand [segment] keyword currently ranking #[rank] and [AI status]. [Win assessment]." // Demand level prefix const demandPrefix = demand_level === 'High' ? 'High-demand' : demand_level === 'Medium' ? 'Medium-demand' : 'Low-demand'; // Segment context const segmentContext = segment && segment.toLowerCase() === 'education' ? 'education ' : segment && segment.toLowerCase() === 'money' ? 'commercial ' : ''; // Rank description if (best_rank_group != null && best_rank_group <= 10) { parts.push(`${demandPrefix} ${segmentContext}keyword currently ranking #${best_rank_group}`); // AI Overview status if (has_ai_overview && ai_citations_ours > 0) { parts.push('and cited in AI Overviews.'); } else if (has_ai_overview) { parts.push('with AI Overview present but not cited.'); } else { parts.push('but not yet cited in AI Overviews.'); } // Win assessment for page 1 keywords if (demand_level === 'High' && has_ai_overview && ai_citations_ours > 0) { parts.push('Good win if you can improve CTR and schema.'); } else if (demand_level === 'High') { parts.push('Good win potential if you can improve CTR, schema, and AI citations.'); } } else { // Page 2+ keywords if (demand_level === 'High') { parts.push(`${demandPrefix} ${segmentContext}keyword currently on page 2+; big upside if you move into page 1.`); } else { parts.push(`${demandPrefix} ${segmentContext}keyword currently on page 2+; opportunity to improve ranking.`); } // Add AI status for page 2+ if present if (has_ai_overview && ai_citations_ours > 0) { parts.push('Cited in AI Overviews.'); } } return parts.join(' '); } /** * Generate action bullets based on scorecard data (always returns 3 bullets) * @param {Object} scorecardData - Data from buildKeywordScorecardData * @returns {Array} Array of exactly 3 action bullet strings */ function generateActionBullets(scorecardData) { const { demand_level, rank_bucket, ai_status, segment, page_type, position_strength } = scorecardData; const standardActions = []; // Bullet 1: Classic ranking & CTR if (demand_level === 'High' && rank_bucket !== 'top3') { standardActions.push('Improve title and meta description for this page to win more clicks for this high-demand term.'); } else if (position_strength === 'Weak') { standardActions.push('Improve title and meta description to improve CTR and push this page into top 10.'); } else { standardActions.push('Continue optimizing title and meta description to maintain strong CTR for this keyword.'); } // Bullet 2: AI usage / authority if (ai_status === 'ai_no_citation') { standardActions.push('Strengthen on-page answer content and schema so AI can confidently cite this page in AI Overviews.'); } else if (ai_status === 'ai_cited_strong' || ai_status === 'ai_cited_light') { if (rank_bucket === 'page1' || rank_bucket === 'page2plus') { standardActions.push('You\'re already cited in AI Overviews – improve classic ranking (links and snippet) to capture more traffic.'); } else { standardActions.push('You\'re already cited in AI Overviews – maintain strong ranking and authority signals.'); } } else { standardActions.push('No AI Overview yet – improve content depth and schema to increase chances of AI citation.'); } // Bullet 3: Commercialisation / internal links if (segment && segment.toLowerCase() === 'education' && page_type === 'Landing') { standardActions.push('Consider adding stronger calls-to-action and internal links from money pages to capture more commercial value from this educational query.'); } else if (segment && segment.toLowerCase() === 'money') { standardActions.push('Strengthen internal links from high-authority pages and optimize conversion elements on this money page.'); } else { standardActions.push('Consider internal linking strategy to boost authority and capture related commercial queries.'); } return standardActions; } /** * Normalize page URL for GSC matching - strips query params, fragments, ensures canonical format * @param {string} url - URL to normalize * @returns {string} Normalized URL path */ function normalizeGscPageUrl(url) { if (!url || typeof url !== 'string') return ''; let cleanUrl = url.trim(); // Strip query parameters (srsltid, utm_*, gclid, fbclid, etc.) and fragments cleanUrl = cleanUrl.split('?')[0].split('#')[0]; try { // Handle relative URLs by adding base URL let urlToParse = cleanUrl; if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) { urlToParse = 'https://www.alanranger.com' + (cleanUrl.startsWith('/') ? cleanUrl : '/' + cleanUrl); } const urlObj = new URL(urlToParse); // Use pathname (automatically excludes query params and hash) let normalized = urlObj.pathname.toLowerCase().replace(/\/$/, '').trim(); // If pathname is empty or just '/', treat as homepage if (!normalized || normalized === '/') { normalized = '/'; } return normalized; } catch (e) { // If URL parsing fails, use manually cleaned URL return cleanUrl.toLowerCase().replace(/\/$/, '').trim() || '/'; } } /** * Get GSC metrics for a keyword row - unified helper for table and scorecard * Returns atomic bundle with clicks, impressions, CTR (decimal), scope, and normalized page URL * @param {Object} params - Object with query (keyword) and pageUrl * @returns {Object} { clicks, impressions, ctrDecimal, scope, pageUsed } or null if no data * - scope: 'query+page' | 'query-only' | 'page-only' | 'none' * - ctrDecimal: 0-1 (decimal format, e.g., 0.0117 for 1.17%) * - impressions: must be > 0 for CTR to be valid */ function getGscMetricsForKeywordRow(params) { try { const { query, pageUrl } = params || {}; if (!query) { return null; } // Get audit data from localStorage const savedAudit = loadAuditResultsSync(); if (!savedAudit?.searchData?.queryPages) { return null; } const queryPages = savedAudit.searchData.queryPages || []; if (!Array.isArray(queryPages) || queryPages.length === 0) { return null; } // Normalize keyword for matching const normalizedKeyword = (query || '').toLowerCase().trim(); if (!normalizedKeyword) { return null; } // Normalize page URL (strip query params, fragments, ensure canonical format) const normalizedPageUrl = pageUrl ? normalizeGscPageUrl(pageUrl) : null; // First try: exact match on keyword + URL (query+page scope) if (normalizedPageUrl) { const exactMatches = queryPages.filter(p => { const pKeyword = (p.query || '').toLowerCase().trim(); const pPage = normalizeGscPageUrl(p.page || p.url || ''); return pKeyword === normalizedKeyword && pPage === normalizedPageUrl; }); if (exactMatches.length > 0) { // Aggregate all matches for this query+page combination let totalImpressions = 0; let totalClicks = 0; exactMatches.forEach(m => { totalImpressions += (m.impressions || 0); totalClicks += (m.clicks || 0); }); // Only return data if impressions > 0 (CTR requires impressions) if (totalImpressions > 0) { // CTR from GSC is stored as percentage (0-100), convert to decimal const ctrDecimal = totalImpressions > 0 ? (totalClicks / totalImpressions) : 0; return { clicks: totalClicks, impressions: totalImpressions, ctrDecimal: ctrDecimal, scope: 'query+page', pageUsed: normalizedPageUrl }; } } } // Second try: match by keyword only (query-only scope) const keywordMatches = queryPages.filter(p => { const pKeyword = (p.query || '').toLowerCase().trim(); return pKeyword === normalizedKeyword; }); if (keywordMatches.length > 0) { let totalImpressions = 0; let totalClicks = 0; keywordMatches.forEach(m => { totalImpressions += (m.impressions || 0); totalClicks += (m.clicks || 0); }); if (totalImpressions > 0) { const ctrDecimal = totalClicks / totalImpressions; return { clicks: totalClicks, impressions: totalImpressions, ctrDecimal: ctrDecimal, scope: 'query-only', pageUsed: null }; } } // Third try: match by URL only (page-only scope) if (normalizedPageUrl) { const urlMatches = queryPages.filter(p => { const pPage = normalizeGscPageUrl(p.page || p.url || ''); return pPage === normalizedPageUrl; }); if (urlMatches.length > 0) { let totalImpressions = 0; let totalClicks = 0; urlMatches.forEach(m => { totalImpressions += (m.impressions || 0); totalClicks += (m.clicks || 0); }); if (totalImpressions > 0) { const ctrDecimal = totalClicks / totalImpressions; return { clicks: totalClicks, impressions: totalImpressions, ctrDecimal: ctrDecimal, scope: 'page-only', pageUsed: normalizedPageUrl }; } } } // No data found return null; } catch (err) { console.error('Error getting GSC metrics for keyword row:', err); return null; } } /** * Get CTR metrics for a keyword from GSC audit data * @deprecated Use getGscMetricsForKeywordRow instead for consistent behavior * @param {Object} key - Object with keyword and url * @returns {Object|null} CTR metrics or null if not found */ function getCtrMetricsForKeyword(key) { // Use unified helper for consistency const metrics = getGscMetricsForKeywordRow({ query: key.keyword, pageUrl: key.url }); if (!metrics) { return null; } // Return in legacy format for backward compatibility return { ctr: metrics.ctrDecimal, impressions: metrics.impressions, clicks: metrics.clicks }; } /** * Get position bucket from rank * @param {number|null} rank - Ranking position * @returns {string} Position bucket: 'top3', '4-10', '11-20', or '20+' */ function getPositionBucket(rank) { if (rank == null) return '20+'; if (rank <= 3) return 'top3'; if (rank <= 10) return '4-10'; if (rank <= 20) return '11-20'; return '20+'; } /** * Get expected CTR benchmark for a position bucket * @param {string} positionBucket - Position bucket: 'top3', '4-10', '11-20', or '20+' * @returns {number} Expected CTR as decimal (e.g., 0.15 for 15%) */ function getCtrBenchmarkForPosition(positionBucket) { // Industry benchmarks for CTR by position const benchmarks = { 'top3': 0.15, // ~15% CTR for positions 1-3 '4-10': 0.05, // ~5% CTR for positions 4-10 '11-20': 0.02, // ~2% CTR for positions 11-20 '20+': 0.01 // ~1% CTR for positions 21+ }; return benchmarks[positionBucket] || 0.01; } /** * Normalize URL by stripping query parameters, hash, and trailing slashes * Returns just the pathname for matching */ function normalizeUrlForMatching(url) { if (!url || typeof url !== 'string') return ''; // Explicitly strip everything from ? onwards (query parameters) and # onwards (hash) // This ensures URLs with Google SERP params like ?srsltid=... are matched correctly let cleanUrl = url.split('?')[0].split('#')[0]; let normalized = cleanUrl.toLowerCase().trim(); try { // Handle relative URLs by adding a base URL let urlToParse = normalized; if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) { urlToParse = 'https://www.alanranger.com' + (normalized.startsWith('/') ? normalized : '/' + normalized); } const urlObj = new URL(urlToParse); // pathname automatically excludes query params and hash, but we already stripped them above normalized = urlObj.pathname.toLowerCase().replace(/\/$/, '').trim(); // If pathname is empty or just '/', treat as homepage if (!normalized || normalized === '/') { normalized = '/'; } } catch (e) { // If URL parsing fails, use the manually cleaned URL normalized = cleanUrl.toLowerCase().replace(/\/$/, '').trim(); // Ensure it starts with / if it's a path if (normalized && !normalized.startsWith('/')) { normalized = '/' + normalized; } if (!normalized || normalized === '/') { normalized = '/'; } } return normalized; } async function getSchemaCoverageForUrl(url) { try { // Get audit data from localStorage using loadAuditResultsSync let savedAudit = loadAuditResultsSync(); if (!savedAudit) { debugLog('[Schema Coverage] No saved audit found in localStorage', 'warn'); return null; } if (!savedAudit.schemaAudit || !savedAudit.schemaAudit.data) { debugLog('[Schema Coverage] No schemaAudit.data in saved audit', 'warn'); return null; } let schemaData = savedAudit.schemaAudit.data; // Check both pages and pagesWithSchema arrays let pagesArray = schemaData.pages || []; let pagesWithSchema = schemaData.pagesWithSchema || []; // Use pages array if available (more reliable), otherwise use pagesWithSchema let allPages = Array.isArray(pagesArray) && pagesArray.length > 0 ? pagesArray : (Array.isArray(pagesWithSchema) && pagesWithSchema.length > 0 ? pagesWithSchema : []); // Normalize the input URL first to use for matching const normalizedUrl = normalizeUrlForMatching(url); debugLog('[Schema Coverage] Looking for normalized URL: ' + normalizedUrl + ' (original: ' + url + ')', 'info'); // Try to find the page in the current array let pageData = allPages.find(p => { if (!p || !p.url) return false; const pNormalized = normalizeUrlForMatching(p.url); const exactMatch = pNormalized === normalizedUrl; const homepageMatch = (normalizedUrl === '/' || normalizedUrl === '') && (pNormalized === '/' || pNormalized === ''); return exactMatch || homepageMatch; }); const domPropertyUrl = document.getElementById('propertyUrl')?.value; const effectivePropertyUrl = domPropertyUrl || savedAudit.propertyUrl || localStorage.getItem('gsc_property_url') || ''; // If not found and we have a truncated array (200 items), try fetching from Supabase // The API truncates to 200, but the full data exists in Supabase if (!pageData && allPages.length === 200) { debugLog('[Schema Coverage] Page not found in truncated array (200 items), fetching full data from Supabase...', 'info'); const propertyUrl = effectivePropertyUrl; try { const supabaseData = await fetchLatestAuditFromSupabase(propertyUrl); if (supabaseData && supabaseData.schemaAudit && supabaseData.schemaAudit.data) { schemaData = supabaseData.schemaAudit.data; pagesArray = schemaData.pages || []; pagesWithSchema = schemaData.pagesWithSchema || []; const supabasePages = Array.isArray(pagesArray) && pagesArray.length > 0 ? pagesArray : (Array.isArray(pagesWithSchema) && pagesWithSchema.length > 0 ? pagesWithSchema : []); debugLog('[Schema Coverage] Loaded schema data from Supabase (' + supabasePages.length + ' pages, may still be truncated)', 'info'); // Search in the Supabase data (may still be truncated, but worth trying) if (supabasePages.length > 0) { pageData = supabasePages.find(p => { if (!p || !p.url) return false; const pNormalized = normalizeUrlForMatching(p.url); const exactMatch = pNormalized === normalizedUrl; const homepageMatch = (normalizedUrl === '/' || normalizedUrl === '') && (pNormalized === '/' || pNormalized === ''); return exactMatch || homepageMatch; }); if (pageData) { debugLog('[Schema Coverage] ✅ Found page in Supabase data', 'success'); allPages = supabasePages; } else if (supabasePages.length === 200) { debugLog('[Schema Coverage] ⚠️ Supabase data also truncated to 200 items. Querying full data from API...', 'info'); // Try the dedicated API endpoint that searches the full JSONB field try { const propertyUrl = effectivePropertyUrl; const urlHelper = window.apiUrl || ((path) => { const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' || !window.location.hostname; const baseUrl = isLocal ? 'https://ai-geo-audit.vercel.app' : ''; const cleanPath = path.startsWith('/') ? path : `/${path}`; return `${baseUrl}${cleanPath}`; }); const apiUrl = urlHelper(`/api/supabase/get-schema-for-url?propertyUrl=${encodeURIComponent(propertyUrl)}&searchUrl=${encodeURIComponent(url)}`); const apiRes = await fetch(apiUrl); if (apiRes.ok) { const apiData = await apiRes.json(); if (apiData.status === 'ok' && apiData.data) { pageData = apiData.data; debugLog('[Schema Coverage] ✅ Found page in full Supabase data via API', 'success'); } else { debugLog('[Schema Coverage] API returned: ' + (apiData.message || 'no data'), 'warn'); } } else { debugLog('[Schema Coverage] API request failed: ' + apiRes.status, 'warn'); } } catch (apiErr) { debugLog('[Schema Coverage] API request error: ' + apiErr.message, 'warn'); } } } } } catch (e) { debugLog('[Schema Coverage] Failed to fetch from Supabase: ' + e.message, 'warn'); } } // If still not found, check if pagesWithSchema is just a count (not an array) if (!pageData && allPages.length === 0 && typeof pagesWithSchema === 'number') { debugLog('[Schema Coverage] pagesWithSchema is a count in localStorage, trying Supabase...', 'warn'); const propertyUrl = effectivePropertyUrl; try { const supabaseData = await fetchLatestAuditFromSupabase(propertyUrl); if (supabaseData && supabaseData.schemaAudit && supabaseData.schemaAudit.data) { schemaData = supabaseData.schemaAudit.data; pagesArray = schemaData.pages || []; pagesWithSchema = schemaData.pagesWithSchema || []; const supabasePages = Array.isArray(pagesArray) && pagesArray.length > 0 ? pagesArray : (Array.isArray(pagesWithSchema) && pagesWithSchema.length > 0 ? pagesWithSchema : []); debugLog('[Schema Coverage] Loaded detailed schema data from Supabase (' + supabasePages.length + ' pages)', 'info'); // Update localStorage with the correct structure savedAudit.schemaAudit.data = schemaData; safeSetLocalStorage('last_audit_results', savedAudit); if (supabasePages.length > 0) { allPages = supabasePages; // Try to find the page in the newly loaded data pageData = allPages.find(p => { if (!p || !p.url) return false; const pNormalized = normalizeUrlForMatching(p.url); const exactMatch = pNormalized === normalizedUrl; const homepageMatch = (normalizedUrl === '/' || normalizedUrl === '') && (pNormalized === '/' || pNormalized === ''); return exactMatch || homepageMatch; }); // If still not found and we only have a truncated 200-page array, query full JSONB via API if (!pageData && allPages.length === 200) { debugLog('[Schema Coverage] Still not found after Supabase load (200 items). Querying full data from API...', 'info'); try { const urlHelper = window.apiUrl || ((path) => { const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' || !window.location.hostname; const baseUrl = isLocal ? 'https://ai-geo-audit.vercel.app' : ''; const cleanPath = path.startsWith('/') ? path : `/${path}`; return `${baseUrl}${cleanPath}`; }); const apiUrl = urlHelper(`/api/supabase/get-schema-for-url?propertyUrl=${encodeURIComponent(propertyUrl)}&searchUrl=${encodeURIComponent(url)}`); const apiRes = await fetch(apiUrl); if (apiRes.ok) { const apiData = await apiRes.json(); if (apiData.status === 'ok' && apiData.data) { pageData = apiData.data; debugLog('[Schema Coverage] ✅ Found page in full Supabase data via API', 'success'); } else { debugLog('[Schema Coverage] API returned: ' + (apiData.message || 'no data'), 'warn'); } } else { debugLog('[Schema Coverage] API request failed: ' + apiRes.status, 'warn'); } } catch (apiErr) { debugLog('[Schema Coverage] API request error: ' + apiErr.message, 'warn'); } } } } } catch (e) { debugLog('[Schema Coverage] Failed to fetch from Supabase: ' + e.message, 'warn'); } } if (!Array.isArray(allPages) || allPages.length === 0) { debugLog('[Schema Coverage] No pages array available (length: ' + (Array.isArray(allPages) ? allPages.length : 'not array') + ')', 'warn'); debugLog('[Schema Coverage] This usually means the schema audit hasn\'t been run yet, or the data is missing from localStorage/Supabase.', 'info'); return null; } // If pageData wasn't found yet, search in allPages if (!pageData) { pageData = allPages.find(p => { if (!p || !p.url) return false; const pNormalized = normalizeUrlForMatching(p.url); // Exact match after normalization (both URLs stripped of query params, hash, trailing slashes) const exactMatch = pNormalized === normalizedUrl; // For homepage, also check if both are '/' or empty const homepageMatch = (normalizedUrl === '/' || normalizedUrl === '') && (pNormalized === '/' || pNormalized === ''); if (exactMatch || homepageMatch) { debugLog('[Schema Coverage] ✅ Exact URL match: ' + p.url + ' -> ' + pNormalized + ' (search: ' + normalizedUrl + ')', 'info'); return true; } return false; }); } if (!pageData) { // Enhanced debugging: show sample URLs and search for similar URLs debugLog('[Schema Coverage] ❌ No page data found for URL: ' + url, 'warn'); debugLog('[Schema Coverage] Normalized search URL: ' + normalizedUrl, 'warn'); debugLog('[Schema Coverage] Total pages in array: ' + allPages.length, 'warn'); if (allPages.length > 0) { debugLog('[Schema Coverage] Sample URLs in pages array (first 10):', 'warn'); allPages.slice(0, 10).forEach((p, idx) => { const pNorm = normalizeUrlForMatching(p.url); debugLog(` ${idx + 1}. ${p.url} -> ${pNorm}`, 'warn'); }); // Try to find similar URLs (contains photography-courses or courses-coventry) const similarUrls = allPages.filter(p => { const pNorm = normalizeUrlForMatching(p.url); return pNorm.includes('photography-courses') || pNorm.includes('courses-coventry') || normalizedUrl.includes(pNorm.split('/').pop() || '') || (pNorm.split('/').pop() || '').includes(normalizedUrl.split('/').pop() || ''); }); if (similarUrls.length > 0) { debugLog('[Schema Coverage] Found similar URLs that might match:', 'warn'); similarUrls.forEach((p, idx) => { const pNorm = normalizeUrlForMatching(p.url); debugLog(` ${idx + 1}. ${p.url} -> ${pNorm}`, 'warn'); }); } } return null; } if (!pageData.schemaTypes) { debugLog('[Schema Coverage] ⚠ Page found but no schemaTypes: ' + pageData.url, 'warn'); return null; } // Extract schema types from array const schemaTypes = Array.isArray(pageData.schemaTypes) ? pageData.schemaTypes : []; // Check for specific schema types (case-insensitive) const typeStrings = schemaTypes.map(t => { if (typeof t === 'string') return t.toLowerCase(); if (t && typeof t === 'object' && t.type && typeof t.type === 'string') return t.type.toLowerCase(); return String(t).toLowerCase(); }).filter(t => t && t !== '[object object]'); return { hasFAQ: typeStrings.some(t => t.includes('faq') || t === 'faqpage'), hasHowTo: typeStrings.some(t => t.includes('howto') || t === 'howto'), hasEvent: typeStrings.some(t => t.includes('event') && !t.includes('product')), hasProduct: typeStrings.some(t => t.includes('product')), hasBreadcrumb: typeStrings.some(t => t.includes('breadcrumb') || t === 'breadcrumblist'), hasImageObject: typeStrings.some(t => t.includes('image') || t === 'imageobject'), schemaTypes: schemaTypes, url: pageData.url }; } catch (err) { console.error('Error getting schema coverage:', err); return null; } } /** * Render Keyword Priority Matrix (3x3 grid: Impact × Difficulty) * @param {Array} filteredRows - Filtered keyword rows * @param {HTMLElement} container - Container element * @param {Function} onCellClick - Callback(filter) */ function renderKeywordPriorityMatrix(filteredRows, container, onCellClick) { if (!filteredRows || !filteredRows.length) { container.innerHTML = '
    No keyword data available.
    '; return; } // Calculate impact and difficulty for each row const rowsWithBuckets = filteredRows.map(row => ({ ...row, impact: calculateImpactBucket(row.demand_share || 0), difficulty: calculateDifficultyBucket(row.best_rank_group) })); // Calculate total demand share for percentages const totalDemandShare = filteredRows.reduce((sum, r) => { const share = r.demand_share || 0; return sum + share; }, 0) || 0.0001; // Avoid division by zero // Calculate total search volume for debug logging const totalSearchVolume = filteredRows.reduce((sum, r) => { const vol = r.search_volume || 0; return sum + (vol > 0 ? vol : 0); }, 0); const impacts = ['high', 'medium', 'low']; const difficulties = ['low', 'medium', 'high']; let html = `
    Impact ↑
    `; for (const impact of impacts) { for (const difficulty of difficulties) { const cellRows = rowsWithBuckets.filter( r => r.impact === impact && r.difficulty === difficulty ); const count = cellRows.length; // Sum demand_share (0-1) for keywords in this cell const demandShareSum = cellRows.reduce((sum, r) => { const share = r.demand_share || 0; return sum + share; }, 0); // Convert to percentage const demandSharePct = totalDemandShare > 0 ? (demandShareSum / totalDemandShare) * 100 : 0; // Calculate average opportunity score for this cell const totalOpportunityScore = cellRows.reduce((sum, r) => { const score = r.opportunityScore ?? 0; return sum + score; }, 0); const avgOpportunityScore = count > 0 ? totalOpportunityScore / count : 0; // Background intensity based on avgOpportunityScore let backgroundColor; if (avgOpportunityScore >= 70) { backgroundColor = '#dcfce7'; // Strong green highlight (High) } else if (avgOpportunityScore >= 40) { backgroundColor = '#fef3c7'; // Medium amber highlight (Medium) } else { backgroundColor = '#f9fafb'; // Light/neutral background (Low) } // RAG styling: High impact + Low/Medium difficulty = high priority (green) const ragClass = impact === 'high' && (difficulty === 'low' || difficulty === 'medium') ? 'rag-high' : impact === 'high' && difficulty === 'high' ? 'rag-medium' : 'rag-low'; // Check if this cell is active const isActive = rankingPriorityFilter && rankingPriorityFilter.impact === impact && rankingPriorityFilter.difficulty === difficulty; // Active state styling const borderColor = isActive ? '#2563eb' : (ragClass === 'rag-high' ? '#10b981' : ragClass === 'rag-medium' ? '#f59e0b' : '#e5e7eb'); const borderWidth = isActive ? '4px' : '2px'; // Override background color if active if (isActive) { backgroundColor = '#dbeafe'; } html += ` `; } } html += `
    Difficulty →
    `; container.innerHTML = html; // Wire up click handlers container.querySelectorAll('.matrix-cell').forEach(btn => { btn.addEventListener('click', () => { const impact = btn.getAttribute('data-impact'); const difficulty = btn.getAttribute('data-difficulty'); // Toggle: if clicking the same cell, clear filter; otherwise set it if (rankingPriorityFilter && rankingPriorityFilter.impact === impact && rankingPriorityFilter.difficulty === difficulty) { rankingPriorityFilter = null; selectedKeywordId = null; // Clear selection when clearing filter } else { rankingPriorityFilter = { impact, difficulty }; } debugLog(`[RankingAI Scorecard] Priority grid cell clicked - impact: ${impact}, difficulty: ${difficulty}`, 'info'); if (onCellClick) onCellClick(rankingPriorityFilter); // After filter is applied, check if exactly one row matches and auto-select it setTimeout(async () => { const { combinedRows } = RankingAiModule.state(); const filteredAfterClick = applyRankingFilters(combinedRows); if (filteredAfterClick.length === 1) { const singleRow = filteredAfterClick[0]; selectedKeywordId = `${singleRow.keyword}|${singleRow.best_url || ''}`; debugLog(`[RankingAI Scorecard] Auto-selecting single matching keyword: "${singleRow.keyword}"`, 'info'); await renderKeywordScorecard(singleRow); // Also update table row selection const tbody = document.getElementById("ranking-ai-table-body"); if (tbody) { tbody.querySelectorAll("tr").forEach(tr => { tr.classList.remove("ranking-table-row--selected"); const idx = Number(tr.dataset.index || "-1"); if (!Number.isNaN(idx) && combinedRows[idx] === singleRow) { tr.classList.add("ranking-table-row--selected"); } }); } } else { selectedKeywordId = null; // Clear selection if multiple or no rows } }, 100); // Small delay to ensure renderRankingAiTab has completed }); }); // Debug logging debugLog(`[Keyword Priority Matrix] Rendered matrix with ${filteredRows.length} filtered rows`, 'info'); rowsWithBuckets.forEach(row => { debugLog(`[Keyword Priority Matrix] "${row.keyword}": impact=${row.impact}, difficulty=${row.difficulty}, demand_share=${((row.demand_share || 0) * 100).toFixed(1)}%, rank=${row.best_rank_group ?? 'null'}`, 'info'); }); // Log cell totals for (const impact of impacts) { for (const difficulty of difficulties) { const cellRows = rowsWithBuckets.filter(r => r.impact === impact && r.difficulty === difficulty); const demandShareSum = cellRows.reduce((sum, r) => sum + ((r.search_volume && r.search_volume > 0 ? r.search_volume : 0)), 0); const demandSharePct = totalSearchVolume > 0 ? (demandShareSum / totalSearchVolume) * 100 : 0; debugLog(`[Keyword Priority Matrix] Cell ${impact}/${difficulty}: count=${cellRows.length}, demand_share=${demandSharePct.toFixed(1)}%`, 'info'); } } } // Rank normalization helper function normalizeRank(rank) { const r = Number(rank); if (!Number.isFinite(r) || r <= 0) return null; // treat missing/unranked return r; } // Apply filters to rows function applyRankingFilters(rows, excludeFilter = null) { return rows.filter(row => { // Segment filter (normalize to lowercase for comparison) if (excludeFilter !== 'segment' && rankingFilterState.segment !== 'all') { const rowSegment = (row.segment || '').toLowerCase(); const filterSegment = rankingFilterState.segment.toLowerCase(); if (rowSegment !== filterSegment) { return false; } } // Rank bucket filter if (excludeFilter !== 'rank' && rankingFilterState.rank !== 'all') { const rank = normalizeRank(row.best_rank_group); if (rankingFilterState.rank === 'top3' && (rank === null || rank > 3)) return false; if (rankingFilterState.rank === '4-10' && (rank === null || rank < 4 || rank > 10)) return false; if (rankingFilterState.rank === '11-20' && (rank === null || rank < 11 || rank > 20)) return false; if (rankingFilterState.rank === '21+' && rank !== null && rank <= 20) return false; if (rankingFilterState.rank === 'not-top3' && rank !== null && rank <= 3) return false; } // Preset-specific rank predicates (when rank filter is 'all' but preset requires specific range) // Note: Most presets now use explicit rank filter values (e.g., 'not-top3'), so this is rarely needed // Keeping for backward compatibility if any presets still use 'all' with predicates if (excludeFilter !== 'rank' && rankingFilterState.rank === 'all' && activePreset) { const rank = normalizeRank(row.best_rank_group); // No preset-specific predicates needed - all presets now use explicit rank filter values } // Search volume filter if (excludeFilter !== 'volume' && rankingFilterState.volume !== 'all') { const volume = row.search_volume; if (rankingFilterState.volume === 'high' && (volume == null || volume < 200)) return false; if (rankingFilterState.volume === 'medium' && (volume == null || volume < 50 || volume >= 200)) return false; if (rankingFilterState.volume === 'low' && (volume == null || volume < 1 || volume >= 50)) return false; if (rankingFilterState.volume === 'none' && volume != null && volume > 0) return false; } // CTR filter if (excludeFilter !== 'ctr' && rankingFilterState.ctr !== 'all') { // Use canonical targetUrl for CTR metrics const urlForCtr = row.targetUrl || row.ranking_url || ''; const ctrMetrics = getCtrMetricsForKeyword({ keyword: row.keyword, url: urlForCtr }); const ctr = ctrMetrics && ctrMetrics.ctr != null ? (ctrMetrics.ctr * 100) : null; if (rankingFilterState.ctr === 'high' && (ctr == null || ctr < 5)) return false; if (rankingFilterState.ctr === 'medium' && (ctr == null || ctr < 2 || ctr >= 5)) return false; if (rankingFilterState.ctr === 'low' && (ctr == null || ctr >= 2)) return false; if (rankingFilterState.ctr === 'none' && ctr != null) return false; } // Demand share filter removed - replaced with Impressions (30d) column // Page type filter if (excludeFilter !== 'pageType' && rankingFilterState.pageType && rankingFilterState.pageType !== 'all') { const rowPageType = row.pageType || 'Landing'; if (rowPageType !== rankingFilterState.pageType) { return false; } } // SERP features filter if (excludeFilter !== 'serpFeatures' && rankingFilterState.serpFeatures !== 'all') { const hasAiOverview = row.ai_overview_present_any === true; const hasLocalPack = row.local_pack_present_any === true; const hasPaa = row.paa_present_any === true; const hasFeaturedSnippet = row.featured_snippet_present_any === true; if (rankingFilterState.serpFeatures === 'ai-overview' && !hasAiOverview) return false; if (rankingFilterState.serpFeatures === 'local-pack' && !hasLocalPack) return false; if (rankingFilterState.serpFeatures === 'paa' && !hasPaa) return false; if (rankingFilterState.serpFeatures === 'featured-snippet' && !hasFeaturedSnippet) return false; if (rankingFilterState.serpFeatures === 'none' && (hasAiOverview || hasLocalPack || hasPaa || hasFeaturedSnippet)) return false; } // AI Overview filter if (excludeFilter !== 'aiOverview' && rankingFilterState.aiOverview !== 'all') { if (rankingFilterState.aiOverview === 'has' && !row.has_ai_overview) return false; if (rankingFilterState.aiOverview === 'no' && row.has_ai_overview) return false; } // AI Citation filter if (excludeFilter !== 'aiCitation' && rankingFilterState.aiCitation !== 'all') { const isCited = row.ai_alan_citations_count > 0; if (rankingFilterState.aiCitation === 'cited' && !isCited) return false; if (rankingFilterState.aiCitation === 'not-cited' && isCited) return false; } // Opportunity band constants const OP_BANDS = { highMin: 70, mediumMin: 40 }; // Opportunity Score filter if (excludeFilter !== 'opportunity' && rankingFilterState.opportunity !== 'all') { const oppScore = row.opportunityScore ?? -1; // Treat null as -1 (below low threshold) if (rankingFilterState.opportunity === 'high' && (oppScore < OP_BANDS.highMin || oppScore > 100)) return false; if (rankingFilterState.opportunity === 'medium' && (oppScore < OP_BANDS.mediumMin || oppScore >= OP_BANDS.highMin)) return false; if (rankingFilterState.opportunity === 'low' && (oppScore >= OP_BANDS.mediumMin || oppScore < 0)) return false; } // Min opportunity filter (applied after band filter if set) if (rankingFilterState.minOpportunity != null) { const oppScore = Number(row.opportunityScore) || 0; if (oppScore < rankingFilterState.minOpportunity) return false; } // Keyword search if (excludeFilter !== 'keyword' && rankingFilterState.keyword) { const kw = rankingFilterState.keyword.toLowerCase(); if (!row.keyword.toLowerCase().includes(kw)) return false; } // Optimisation status filter if (excludeFilter !== 'optimisationStatus' && rankingFilterState.optimisationStatus !== 'all') { const taskType = 'on_page'; // Default task type const status = window.getOptimisationStatus ? window.getOptimisationStatus(row, taskType) : null; const rowStatus = status && status.status ? status.status : 'not-tracked'; if (rankingFilterState.optimisationStatus === 'not-tracked' && rowStatus !== 'not-tracked') return false; if (rankingFilterState.optimisationStatus === 'planned' && rowStatus !== 'planned') return false; if (rankingFilterState.optimisationStatus === 'in_progress' && rowStatus !== 'in_progress') return false; if (rankingFilterState.optimisationStatus === 'monitoring' && rowStatus !== 'monitoring') return false; if (rankingFilterState.optimisationStatus === 'done' && rowStatus !== 'done') return false; if (rankingFilterState.optimisationStatus === 'paused' && rowStatus !== 'paused') return false; if (rankingFilterState.optimisationStatus === 'cancelled' && rowStatus !== 'cancelled') return false; } // Priority matrix filter (Impact × Difficulty) if (rankingPriorityFilter) { const impact = calculateImpactBucket(row.demand_share || 0); const difficulty = calculateDifficultyBucket(row.best_rank_group); if (impact !== rankingPriorityFilter.impact || difficulty !== rankingPriorityFilter.difficulty) { return false; } } return true; }); } // Sort rows function sortRankingRows(rows) { const sorted = [...rows]; sorted.sort((a, b) => { let aVal, bVal; switch (rankingSortState.column) { case 'keyword': aVal = a.keyword.toLowerCase(); bVal = b.keyword.toLowerCase(); break; case 'segment': aVal = a.segment; bVal = b.segment; break; case 'rank': aVal = a.best_rank_group ?? 999; bVal = b.best_rank_group ?? 999; break; case 'citations': aVal = a.ai_alan_citations_count ?? 0; bVal = b.ai_alan_citations_count ?? 0; break; case 'volume': aVal = a.search_volume ?? 0; bVal = b.search_volume ?? 0; break; case 'ctr': const aCtr = getCtrMetricsForKeyword({ keyword: a.keyword, url: a.ranking_url }); const bCtr = getCtrMetricsForKeyword({ keyword: b.keyword, url: b.ranking_url }); aVal = (aCtr && aCtr.ctr != null) ? aCtr.ctr : -1; // Put nulls at end bVal = (bCtr && bCtr.ctr != null) ? bCtr.ctr : -1; break; case 'impressions30d': aVal = a.impressions30d ?? -1; // Put nulls at end bVal = b.impressions30d ?? -1; break; case 'opportunityScore': aVal = a.opportunityScore ?? -1; // Put nulls at end bVal = b.opportunityScore ?? -1; break; case 'pageType': aVal = a.pageType || 'Landing'; bVal = b.pageType || 'Landing'; break; default: return 0; } if (aVal < bVal) return rankingSortState.direction === 'asc' ? -1 : 1; if (aVal > bVal) return rankingSortState.direction === 'asc' ? 1 : -1; // Secondary sort: for high-impact-money, sort by rank (asc) when opportunity scores are equal if (rankingSortState.column === 'opportunityScore' && activePreset === 'high-impact-money') { const aRank = normalizeRank(a.best_rank_group) ?? 999; const bRank = normalizeRank(b.best_rank_group) ?? 999; if (aRank < bRank) return -1; if (aRank > bRank) return 1; } return 0; }); return sorted; } // Toggle metric pill details function toggleMetricPillDetails(cardId) { const card = document.getElementById(cardId); if (!card) return; const detailsEl = card.querySelector(".metric-pill-details"); const toggleEl = card.querySelector(".metric-pill-toggle"); if (detailsEl && toggleEl) { const isExpanded = detailsEl.classList.contains("expanded"); if (isExpanded) { detailsEl.classList.remove("expanded"); toggleEl.textContent = "Show details"; } else { detailsEl.classList.add("expanded"); toggleEl.textContent = "Hide details"; } } } // Set all metric pill details to be expanded by default on desktop function expandAllMetricPillDetails() { const pillIds = ['ranking-card-ai-coverage', 'ranking-card-ai-citations', 'ranking-card-ai-citations-money', 'ranking-card-top10', 'ranking-card-serp-features']; pillIds.forEach(cardId => { const card = document.getElementById(cardId); if (card) { const detailsEl = card.querySelector(".metric-pill-details"); const toggleEl = card.querySelector(".metric-pill-toggle"); if (detailsEl && toggleEl && detailsEl.innerHTML.trim() !== '') { detailsEl.classList.add("expanded"); toggleEl.textContent = "Hide details"; } } }); } // Update metric pills with RAG function updateMetricPills(filteredRows, allRows) { const totalKeywords = allRows.length; const filteredCount = filteredRows.length; // Check if filters are active (if filtered count differs from total, filters are active) const filtersActive = filteredCount !== totalKeywords; // Use filtered rows if filters are active, otherwise use all rows const rowsToUse = filtersActive ? filteredRows : allRows; const countToUse = filtersActive ? filteredCount : totalKeywords; // Calculate metrics from rowsToUse (filtered if filters active, all if not) const withAiOverview = rowsToUse.filter(r => r.has_ai_overview).length; const withAiCitation = rowsToUse.filter(r => r.ai_alan_citations_count > 0).length; const top10 = rowsToUse.filter(r => r.best_rank_group != null && r.best_rank_group <= 10).length; const aiCoveragePct = countToUse > 0 ? Math.round((withAiOverview / countToUse) * 100) : 0; const aiCitationPct = countToUse > 0 ? Math.round((withAiCitation / countToUse) * 100) : 0; const top10Pct = countToUse > 0 ? Math.round((top10 / countToUse) * 100) : 0; // AI Overview coverage const coverageCard = document.getElementById("ranking-card-ai-coverage"); if (coverageCard) { const valueEl = coverageCard.querySelector(".metric-pill-value[data-field='value']"); const statusEl = coverageCard.querySelector(".metric-pill-status[data-field='status']"); const detailsEl = coverageCard.querySelector(".metric-pill-details"); if (valueEl) valueEl.textContent = `${withAiOverview}/${countToUse} (${aiCoveragePct}%)`; let ragClass = 'red'; let statusLabel = 'Low'; if (aiCoveragePct >= 80) { ragClass = 'green'; statusLabel = 'Strong'; } else if (aiCoveragePct >= 40) { ragClass = 'amber'; statusLabel = 'Moderate'; } coverageCard.className = `metric-pill metric-pill--${ragClass}`; if (statusEl) statusEl.textContent = statusLabel; // Update details if (detailsEl) { const withoutOverview = countToUse - withAiOverview; const pctWithoutOverview = countToUse > 0 ? Math.round((withoutOverview / countToUse) * 100) : 0; detailsEl.innerHTML = `

    How often any AI Overview appears for your tracked keywords.

    • With AI Overview: ${withAiOverview}/${countToUse} (${aiCoveragePct}%)
    • Without AI Overview: ${withoutOverview}/${countToUse} (${pctWithoutOverview}%)

    Counted per keyword. It does not matter which domains are cited.

    `; } } // AI Citations const citationsCard = document.getElementById("ranking-card-ai-citations"); if (citationsCard) { const valueEl = citationsCard.querySelector(".metric-pill-value[data-field='value']"); const statusEl = citationsCard.querySelector(".metric-pill-status[data-field='status']"); const detailsEl = citationsCard.querySelector(".metric-pill-details"); if (valueEl) valueEl.textContent = `${withAiCitation}/${countToUse} (${aiCitationPct}%)`; let ragClass = 'red'; let statusLabel = 'Not cited'; if (aiCitationPct >= 60) { ragClass = 'green'; statusLabel = 'Strong'; } else if (aiCitationPct >= 30) { ragClass = 'amber'; statusLabel = 'Some'; } citationsCard.className = `metric-pill metric-pill--${ragClass}`; if (statusEl) statusEl.textContent = statusLabel; // Calculate total citations const totalCitations = rowsToUse.reduce((sum, r) => sum + (r.ai_alan_citations_count || 0), 0); // Calculate withOverviewNoCitation (keywords with AI Overview but no alanranger.com citation) const withOverviewNoCitation = rowsToUse.filter(r => r.has_ai_overview && r.ai_alan_citations_count === 0).length; const pctWithOverviewNoCitation = countToUse > 0 ? Math.round((withOverviewNoCitation / countToUse) * 100) : 0; // Update details if (detailsEl) { detailsEl.innerHTML = `

    How often alanranger.com is actually cited inside the AI Overview.

    • Keywords with citations: ${withAiCitation}/${countToUse} (${aiCitationPct}%)
    • With AI Overview but no alanranger.com citation: ${withOverviewNoCitation}/${countToUse} (${pctWithOverviewNoCitation}%)
    • Total citations across all keywords: ${totalCitations}

    A single keyword can cite alanranger.com multiple times; all those links are counted in "Total citations".

    `; } } // AI Citations (Money pages) try { const rankingAiDataRaw = localStorage.getItem('rankingAiData'); const parsed = rankingAiDataRaw ? JSON.parse(rankingAiDataRaw) : null; renderRankingAiMoneyCitationsTile(parsed); } catch (e) { // Ignore if localStorage missing/corrupt } // Top-10 coverage const top10Card = document.getElementById("ranking-card-top10"); if (top10Card) { const valueEl = top10Card.querySelector(".metric-pill-value[data-field='value']"); const statusEl = top10Card.querySelector(".metric-pill-status[data-field='status']"); const detailsEl = top10Card.querySelector(".metric-pill-details"); if (valueEl) valueEl.textContent = `${top10}/${countToUse} (${top10Pct}%)`; // Calculate rank buckets const top3 = rowsToUse.filter(r => r.best_rank_group != null && r.best_rank_group <= 3).length; const top3Pct = countToUse > 0 ? Math.round((top3 / countToUse) * 100) : 0; const rank11to20 = rowsToUse.filter(r => r.best_rank_group != null && r.best_rank_group >= 11 && r.best_rank_group <= 20).length; const rank11to20Pct = countToUse > 0 ? Math.round((rank11to20 / countToUse) * 100) : 0; const notRanked = rowsToUse.filter(r => r.best_rank_group == null || r.best_rank_group > 20).length; const notRankedPct = countToUse > 0 ? Math.round((notRanked / countToUse) * 100) : 0; let ragClass = 'red'; let statusLabel = 'Weak'; if (top10Pct >= 60) { ragClass = 'green'; statusLabel = 'Strong'; } else if (top10Pct >= 30) { ragClass = 'amber'; statusLabel = 'OK'; } top10Card.className = `metric-pill metric-pill--${ragClass}`; if (statusEl) statusEl.textContent = statusLabel; // Calculate top10Only (positions 4-10) const top10Only = top10 - top3; const pctTop10Only = countToUse > 0 ? Math.round((top10Only / countToUse) * 100) : 0; const pct11to20 = countToUse > 0 ? Math.round((rank11to20 / countToUse) * 100) : 0; const pctNotRanked = countToUse > 0 ? Math.round((notRanked / countToUse) * 100) : 0; // Update details if (detailsEl) { detailsEl.innerHTML = `

    Distribution of best classic (blue-link) rankings across your tracked keywords.

    • Top 3 (positions 1–3): ${top3}/${countToUse} (${top3Pct}%)
    • Positions 4–10: ${top10Only}/${countToUse} (${pctTop10Only}%)
    • Positions 11–20: ${rank11to20}/${countToUse} (${pct11to20}%)
    • Not ranked (21+): ${notRanked}/${countToUse} (${pctNotRanked}%)

    "Top 3" and "4–10" together make up your total Top-10 coverage.

    `; } } // SERP feature coverage const serpFeaturesCard = document.getElementById("ranking-card-serp-features"); if (serpFeaturesCard) { // Count keywords with each SERP feature const withAiOverview = rowsToUse.filter(r => r.ai_overview_present_any === true || r.has_ai_overview === true).length; const withLocalPack = rowsToUse.filter(r => r.local_pack_present_any === true || (r.serp_features && r.serp_features.local_pack === true)).length; const withPaa = rowsToUse.filter(r => r.paa_present_any === true || (r.serp_features && r.serp_features.people_also_ask === true)).length; const withFeaturedSnippet = rowsToUse.filter(r => r.featured_snippet_present_any === true || (r.serp_features && r.serp_features.featured_snippet === true)).length; // Calculate average feature presence (0-100%) const avgFeaturePresence = countToUse > 0 ? Math.round(((withAiOverview + withLocalPack + withPaa + withFeaturedSnippet) / (countToUse * 4)) * 100) : 0; // Calculate individual percentages const aiOverviewPct = countToUse > 0 ? Math.round((withAiOverview / countToUse) * 100) : 0; const localPackPct = countToUse > 0 ? Math.round((withLocalPack / countToUse) * 100) : 0; const paaPct = countToUse > 0 ? Math.round((withPaa / countToUse) * 100) : 0; const featuredSnippetPct = countToUse > 0 ? Math.round((withFeaturedSnippet / countToUse) * 100) : 0; const valueEl = serpFeaturesCard.querySelector(".metric-pill-value[data-field='value']"); const statusEl = serpFeaturesCard.querySelector(".metric-pill-status[data-field='status']"); const detailsEl = serpFeaturesCard.querySelector(".metric-pill-details"); // Show as "X/4 features (Y%)" format const featuresPresent = [withAiOverview, withLocalPack, withPaa, withFeaturedSnippet].filter(count => count > 0).length; if (valueEl) valueEl.textContent = `${featuresPresent}/4 features (${avgFeaturePresence}%)`; let ragClass = 'red'; let statusLabel = 'Low'; if (avgFeaturePresence >= 70) { ragClass = 'green'; statusLabel = 'Strong'; } else if (avgFeaturePresence >= 40) { ragClass = 'amber'; statusLabel = 'Moderate'; } serpFeaturesCard.className = `metric-pill metric-pill--${ragClass}`; if (statusEl) statusEl.textContent = statusLabel; // Update details content if (detailsEl) { detailsEl.innerHTML = `

    How often rich SERP features appear for your tracked keywords.

    • AI Overview present: ${withAiOverview}/${countToUse} (${aiOverviewPct}%)
    • Local pack present: ${withLocalPack}/${countToUse} (${localPackPct}%)
    • People Also Ask present: ${withPaa}/${countToUse} (${paaPct}%)
    • Featured snippet present: ${withFeaturedSnippet}/${countToUse} (${featuredSnippetPct}%)

    Counts are per keyword. A single SERP can contain multiple features (for example AI Overview + People Also Ask).

    `; } } } // Update filter counts in dropdowns // Counts show how many items match each option given the current state of OTHER filters function updateFilterCounts(rows) { if (!rows || rows.length === 0) return; // For each filter, calculate counts based on rows that match all OTHER filters // Segment counts (excluding segment filter) const segmentRows = applyRankingFilters(rows, 'segment'); const segmentCounts = { all: segmentRows.length, brand: 0, money: 0, education: 0, other: 0 }; segmentRows.forEach(r => { const seg = (r.segment || '').toLowerCase(); if (segmentCounts.hasOwnProperty(seg)) segmentCounts[seg]++; else segmentCounts.other++; }); updateSelectCounts('ranking-filter-segment', segmentCounts); // Rank counts (excluding rank filter) const rankRows = applyRankingFilters(rows, 'rank'); const rankCounts = { all: rankRows.length, top3: 0, '4-10': 0, '11-20': 0, '21+': 0, 'not-top3': 0 }; rankRows.forEach(r => { const rank = normalizeRank(r.best_rank_group); if (rank !== null && rank <= 3) { rankCounts.top3++; } else { // Count rows that are NOT top 3 (rank > 3 or null) rankCounts['not-top3']++; } if (rank !== null && rank >= 4 && rank <= 10) rankCounts['4-10']++; if (rank !== null && rank >= 11 && rank <= 20) rankCounts['11-20']++; if (rank === null || rank >= 21) rankCounts['21+']++; }); updateSelectCounts('ranking-filter-rank', rankCounts); // Volume counts (excluding volume filter) const volumeRows = applyRankingFilters(rows, 'volume'); const volumeCounts = { all: volumeRows.length, high: 0, medium: 0, low: 0, none: 0 }; volumeRows.forEach(r => { const vol = r.search_volume; if (vol == null || vol === 0) volumeCounts.none++; else if (vol < 50) volumeCounts.low++; else if (vol < 200) volumeCounts.medium++; else volumeCounts.high++; }); updateSelectCounts('ranking-filter-volume', volumeCounts); // CTR counts (excluding CTR filter) const ctrRows = applyRankingFilters(rows, 'ctr'); const ctrCounts = { all: ctrRows.length, high: 0, medium: 0, low: 0, none: 0 }; ctrRows.forEach(r => { const ctrMetrics = getCtrMetricsForKeyword({ keyword: r.keyword, url: r.ranking_url }); const ctr = ctrMetrics && ctrMetrics.ctr != null ? (ctrMetrics.ctr * 100) : null; if (ctr == null) ctrCounts.none++; else if (ctr < 2) ctrCounts.low++; else if (ctr < 5) ctrCounts.medium++; else ctrCounts.high++; }); updateSelectCounts('ranking-filter-ctr', ctrCounts); // Demand share filter removed - replaced with Impressions (30d) column // Opportunity score counts (excluding opportunity filter) const oppRows = applyRankingFilters(rows, 'opportunity'); const oppCounts = { all: oppRows.length, high: 0, medium: 0, low: 0 }; oppRows.forEach(r => { const opp = r.opportunityScore ?? -1; if (opp >= 70) oppCounts.high++; else if (opp >= 40) oppCounts.medium++; else if (opp >= 0) oppCounts.low++; }); updateSelectCounts('ranking-filter-opportunity', oppCounts); // AI Overview counts (excluding AI Overview filter) const aiOverviewRows = applyRankingFilters(rows, 'aiOverview'); const aiOverviewCounts = { all: aiOverviewRows.length, has: 0, no: 0 }; aiOverviewRows.forEach(r => { if (r.has_ai_overview) aiOverviewCounts.has++; else aiOverviewCounts.no++; }); updateSelectCounts('ranking-filter-ai-overview', aiOverviewCounts); // AI Citation counts (excluding AI Citation filter) const aiCitationRows = applyRankingFilters(rows, 'aiCitation'); const aiCitationCounts = { all: aiCitationRows.length, cited: 0, 'not-cited': 0 }; aiCitationRows.forEach(r => { if (r.ai_alan_citations_count > 0) aiCitationCounts.cited++; else aiCitationCounts['not-cited']++; }); updateSelectCounts('ranking-filter-ai-citation', aiCitationCounts); // Page type counts (excluding page type filter) const pageTypeRows = applyRankingFilters(rows, 'pageType'); const pageTypeCounts = { all: pageTypeRows.length, GBP: 0, Blog: 0, Landing: 0, Event: 0, Product: 0, Other: 0 }; pageTypeRows.forEach(r => { const pt = r.pageType || 'Landing'; if (pageTypeCounts.hasOwnProperty(pt)) pageTypeCounts[pt]++; else pageTypeCounts.Other++; }); updateSelectCounts('ranking-filter-page-type', pageTypeCounts); // SERP features counts (excluding SERP features filter) const serpRows = applyRankingFilters(rows, 'serpFeatures'); const serpCounts = { all: serpRows.length, 'ai-overview': 0, 'local-pack': 0, paa: 0, 'featured-snippet': 0, none: 0 }; serpRows.forEach(r => { const hasAi = r.ai_overview_present_any === true; const hasLocal = r.local_pack_present_any === true; const hasPaa = r.paa_present_any === true; const hasFeatured = r.featured_snippet_present_any === true; if (hasAi) serpCounts['ai-overview']++; if (hasLocal) serpCounts['local-pack']++; if (hasPaa) serpCounts.paa++; if (hasFeatured) serpCounts['featured-snippet']++; if (!hasAi && !hasLocal && !hasPaa && !hasFeatured) serpCounts.none++; }); updateSelectCounts('ranking-filter-serp-features', serpCounts); // Optimisation status counts (excluding optimisation status filter) const optimisationRows = applyRankingFilters(rows, 'optimisationStatus'); const optimisationCounts = { all: optimisationRows.length, 'not-tracked': 0, 'planned': 0, 'in_progress': 0, 'monitoring': 0, 'done': 0, 'paused': 0, 'cancelled': 0 }; optimisationRows.forEach(r => { const taskType = 'on_page'; // Default task type const status = window.getOptimisationStatus ? window.getOptimisationStatus(r, taskType) : null; const rowStatus = status && status.status ? status.status : 'not-tracked'; if (optimisationCounts.hasOwnProperty(rowStatus)) { optimisationCounts[rowStatus]++; } else { optimisationCounts['not-tracked']++; } }); updateSelectCounts('ranking-filter-optimisation-status', optimisationCounts); } function updateSelectCounts(selectId, counts) { const select = document.getElementById(selectId); if (!select) return; // Store original labels (without counts) for each option const originalLabels = {}; Array.from(select.options).forEach(opt => { const value = opt.value; if (!originalLabels[value]) { // Store original label (remove existing count if any) originalLabels[value] = opt.textContent.split(' (')[0].trim(); } }); // Update with counts - ensure ALL options get counts, even if 0 Array.from(select.options).forEach(opt => { const value = opt.value; const label = originalLabels[value] || opt.textContent.split(' (')[0].trim(); if (counts.hasOwnProperty(value)) { const count = counts[value]; opt.textContent = value === 'all' ? `All (${count})` : `${label} (${count})`; } else { // If count not found, show 0 (for options that might not have been in the data) opt.textContent = value === 'all' ? `All (0)` : `${label} (0)`; } }); } // Helper function to get query-only totals for a keyword (PATCH A2) // Helper function to normalize keywords for matching function normalizeKeywordForMatching(keyword) { if (!keyword) return ''; // Normalize: lowercase, trim, collapse multiple spaces to single space return keyword.toLowerCase().trim().replace(/\s+/g, ' '); } function getQueryTotalForKeyword(keyword) { try { const savedAudit = loadAuditResultsSync(); if (!savedAudit || !savedAudit.searchData) { debugLog(`getQueryTotalForKeyword: No queryTotals found for "${keyword}" - savedAudit=${!!savedAudit}, searchData=${!!savedAudit?.searchData}`, 'warn'); return null; } // CRITICAL: Check if queryTotals exists and handle object vs array let queryTotals = savedAudit.searchData.queryTotals; if (!queryTotals) { debugLog(`getQueryTotalForKeyword: No queryTotals found for "${keyword}" - queryTotals is null/undefined`, 'warn'); return null; } // If queryTotals is an object instead of an array, try to convert it if (typeof queryTotals === 'object' && !Array.isArray(queryTotals)) { debugLog(`getQueryTotalForKeyword: queryTotals is an object, not an array. Type: ${typeof queryTotals}, keys: ${Object.keys(queryTotals).join(', ')}`, 'warn'); // Try to extract array from object if (Object.keys(queryTotals).every(key => !isNaN(parseInt(key)))) { // Array-like object with numeric keys queryTotals = Object.values(queryTotals); debugLog(`getQueryTotalForKeyword: Converted array-like object to array (${queryTotals.length} items)`, 'info'); } else { // Check if there's an array property inside the object for (const key in queryTotals) { if (Array.isArray(queryTotals[key])) { queryTotals = queryTotals[key]; debugLog(`getQueryTotalForKeyword: Extracted array from object at key '${key}' (${queryTotals.length} items)`, 'info'); break; } } } } if (!Array.isArray(queryTotals)) { debugLog(`getQueryTotalForKeyword: queryTotals is still not an array after conversion attempt. Type: ${typeof queryTotals}`, 'warn'); return null; } if (queryTotals.length === 0) { debugLog(`getQueryTotalForKeyword: queryTotals array is empty`, 'warn'); return null; } // Normalize the search keyword const normalizedKeyword = normalizeKeywordForMatching(keyword); // Try exact match first (normalized) let queryTotal = queryTotals.find( qt => qt.query && normalizeKeywordForMatching(qt.query) === normalizedKeyword ); // If no exact match, try fuzzy matching (check if keyword is contained in query or vice versa) if (!queryTotal) { queryTotal = queryTotals.find( qt => { if (!qt.query) return false; const normalizedQuery = normalizeKeywordForMatching(qt.query); // Check if normalized keyword is contained in normalized query or vice versa return normalizedQuery === normalizedKeyword || normalizedQuery.includes(normalizedKeyword) || normalizedKeyword.includes(normalizedQuery); } ); } if (!queryTotal) { // Enhanced debugging: show sample queries from queryTotals to help diagnose mismatches const sampleQueries = queryTotals.slice(0, 10).map(qt => qt.query || '(no query)').join(', '); debugLog(`getQueryTotalForKeyword: No match found for "${keyword}" (normalized: "${normalizedKeyword}") in ${queryTotals.length} queryTotals. Sample queries: ${sampleQueries}${queryTotals.length > 10 ? '...' : ''}`, 'warn'); // Also check if there are any queries with zero impressions (these might be the missing ones) const zeroImpressionQueries = queryTotals.filter(qt => qt.impressions === 0 || qt.impressions == null).slice(0, 5).map(qt => qt.query || '(no query)').join(', '); if (zeroImpressionQueries) { debugLog(`getQueryTotalForKeyword: Found ${queryTotals.filter(qt => qt.impressions === 0 || qt.impressions == null).length} queries with zero impressions. Sample: ${zeroImpressionQueries}`, 'info'); } } return queryTotal || null; } catch (error) { debugLog(`Error in getQueryTotalForKeyword: ${error.message}`, 'warn'); return null; } } async function renderRankingAiTab() { debugLog('📊 renderRankingAiTab() called', 'info'); const { combinedRows, summary } = RankingAiModule.state(); debugLog(`📊 renderRankingAiTab: combinedRows=${combinedRows?.length || 0}, hasSummary=${!!summary}`, 'info'); // Always render table, even if no data (will show empty state) const tbody = document.getElementById("ranking-ai-table-body"); if (!tbody) { debugLog('⚠ renderRankingAiTab: Table body not found', 'warn'); return; } if (!summary || !Array.isArray(combinedRows) || combinedRows.length === 0) { debugLog(`⚠ renderRankingAiTab: No data - summary=${!!summary}, combinedRows is array=${Array.isArray(combinedRows)}, length=${combinedRows?.length || 0}`, 'warn'); // Show empty state message tbody.innerHTML = 'Click "Run ranking & AI check" to load data.'; const paginationControls = document.getElementById("ranking-pagination-controls"); if (paginationControls) paginationControls.style.display = "none"; return; } debugLog(`✓ renderRankingAiTab: Proceeding with ${combinedRows.length} keywords`, 'success'); // Apply filters first const filteredRows = applyRankingFilters(combinedRows); // Update filter counts based on all rows (before filtering) updateFilterCounts(combinedRows); // Update preset button active states and render criteria chips if (typeof updatePresetButtonActiveStates === 'function') { updatePresetButtonActiveStates(); } if (typeof renderPresetCriteriaChips === 'function') { renderPresetCriteriaChips(); } // Note: demand_share is no longer recalculated from filtered rows // It remains as originally calculated from all tracked keywords (global-fixed) // Used internally for opportunity score calculation only // Calculate visibility metrics from filtered subset const validRankingRows = filteredRows.filter( r => r.best_rank_group !== null && typeof r.best_rank_group === 'number' ); let avgPositionUnweighted = null; let avgPositionVolumeWeighted = null; if (validRankingRows.length >= 1) { // Unweighted average position const sumRanks = validRankingRows.reduce((sum, r) => sum + r.best_rank_group, 0); avgPositionUnweighted = sumRanks / validRankingRows.length; // Demand-weighted average position let sumWeightedRanks = 0; let sumVolumes = 0; for (const row of validRankingRows) { const vol = (row.search_volume !== null && row.search_volume !== undefined && row.search_volume > 0) ? row.search_volume : 10; // Fallback sumWeightedRanks += row.best_rank_group * vol; sumVolumes += vol; } if (sumVolumes > 0) { avgPositionVolumeWeighted = sumWeightedRanks / sumVolumes; } } // Display tracked keyword visibility metrics (DataForSEO only - not part of AIO pillars) const visibilityMetricsSection = document.getElementById('ranking-visibility-metrics'); const avgPositionWeightedEl = document.getElementById('ranking-avg-position-weighted'); const avgPositionUnweightedEl = document.getElementById('ranking-avg-position-unweighted'); if (visibilityMetricsSection) { // Always show the visibility metrics section (it's part of the side-by-side layout) visibilityMetricsSection.style.display = 'block'; if (avgPositionVolumeWeighted !== null && avgPositionVolumeWeighted !== undefined) { visibilityMetricsSection.style.display = 'block'; if (avgPositionWeightedEl) { avgPositionWeightedEl.textContent = avgPositionVolumeWeighted.toFixed(2); } if (avgPositionUnweightedEl && avgPositionUnweighted !== null && avgPositionUnweighted !== undefined) { avgPositionUnweightedEl.textContent = avgPositionUnweighted.toFixed(2); } else if (avgPositionUnweightedEl) { avgPositionUnweightedEl.textContent = '—'; } } else { visibilityMetricsSection.style.display = 'block'; if (avgPositionWeightedEl) { avgPositionWeightedEl.textContent = '—'; avgPositionWeightedEl.title = 'Not enough valid keywords to calculate.'; } if (avgPositionUnweightedEl) { avgPositionUnweightedEl.textContent = '—'; avgPositionUnweightedEl.title = 'Not enough valid keywords to calculate.'; } } } // Update metric pills from filtered data updateMetricPills(filteredRows, combinedRows); // Expand all metric pill details by default setTimeout(() => expandAllMetricPillDetails(), 100); // Render Keyword Priority Matrix const matrixContainer = document.getElementById('ranking-keyword-priority-matrix'); const matrixSection = document.getElementById('ranking-priority-matrix-section'); if (matrixContainer && filteredRows.length > 0) { if (matrixSection) matrixSection.style.display = 'block'; renderKeywordPriorityMatrix(filteredRows, matrixContainer, (filter) => { // Filter changed - re-render table renderRankingAiTab(); }); } else if (matrixSection) { matrixSection.style.display = 'none'; } // Sort filtered rows const sortedRows = sortRankingRows(filteredRows); // Calculate pagination const totalRows = sortedRows.length; const rowsPerPage = rankingPaginationState.rowsPerPage === 'all' ? totalRows : rankingPaginationState.rowsPerPage; const totalPages = rowsPerPage > 0 ? Math.ceil(totalRows / rowsPerPage) : 1; const currentPage = Math.min(Math.max(1, rankingPaginationState.currentPage), totalPages); rankingPaginationState.currentPage = currentPage; const startIdx = rowsPerPage === 'all' ? 0 : (currentPage - 1) * rowsPerPage; const endIdx = rowsPerPage === 'all' ? totalRows : Math.min(startIdx + rowsPerPage, totalRows); const paginatedRows = sortedRows.slice(startIdx, endIdx); // -------- Fetch optimisation statuses (Phase 2) -------- if (typeof window.fetchOptimisationStatuses === 'function') { await window.fetchOptimisationStatuses(sortedRows); } // -------- Keyword table -------- // tbody already declared at top of function, just clear it tbody.innerHTML = ""; if (!sortedRows.length) { const tr = document.createElement("tr"); const td = document.createElement("td"); td.colSpan = 12; // Updated: 12 columns (Keyword, Segment, Best rank, Search volume, CTR, Impressions (30d), Opportunity score, AI Overview, AI citations, Classic Ranking URL, Page type, SERP features) td.className = "ranking-table-empty"; td.textContent = filteredRows.length === 0 && combinedRows.length > 0 ? "No rows match the current filters." : "No data returned from ranking / AI endpoints."; tr.appendChild(td); tbody.appendChild(tr); // Hide pagination when no data const paginationControls = document.getElementById("ranking-pagination-controls"); if (paginationControls) paginationControls.style.display = "none"; return; } // Store original index mapping for detail panel const originalIndexMap = new Map(); paginatedRows.forEach((row, idx) => { const origIdx = combinedRows.indexOf(row); originalIndexMap.set(idx, origIdx >= 0 ? origIdx : idx); }); paginatedRows.forEach((row, index) => { try { const tr = document.createElement("tr"); tr.dataset.index = String(originalIndexMap.get(index)); // Keyword const tdKeyword = document.createElement("td"); tdKeyword.textContent = row.keyword; tr.appendChild(tdKeyword); // Segment const tdSegment = document.createElement("td"); const segBadge = document.createElement("span"); const segLower = (row.segment || "").toLowerCase(); segBadge.className = "ranking-badge " + ( segLower === "money" ? "ranking-badge--segment-money" : segLower === "education" ? "ranking-badge--segment-education" : segLower === "brand" ? "ranking-badge--segment-brand" : "ranking-badge--segment-general" ); segBadge.textContent = row.segment || "Other"; tdSegment.appendChild(segBadge); tr.appendChild(tdSegment); // Best rank with RAG badge const tdRank = document.createElement("td"); if (row.best_rank_group == null) { const badge = document.createElement("span"); badge.className = "ranking-badge-rank ranking-badge-rank--weak"; badge.textContent = "—"; tdRank.appendChild(badge); } else { const badge = document.createElement("span"); let ragClass = "ranking-badge-rank--weak"; if (row.best_rank_group <= 10) ragClass = "ranking-badge-rank--good"; else if (row.best_rank_group <= 20) ragClass = "ranking-badge-rank--ok"; badge.className = `ranking-badge-rank ${ragClass}`; badge.textContent = `#${row.best_rank_group}`; tdRank.appendChild(badge); } tr.appendChild(tdRank); // Search volume with RAG badge const tdVolume = document.createElement("td"); const volumeBadge = document.createElement("span"); const searchVolume = row.search_volume; // IMPORTANT: Only treat as missing if null/undefined, not if 0 (0 is a valid value) if (searchVolume == null || searchVolume === undefined) { volumeBadge.className = "ranking-badge-volume ranking-badge-volume--none"; volumeBadge.textContent = "—"; } else { const formatted = searchVolume.toLocaleString(); let ragClass = "ranking-badge-volume--low"; let label = "Low"; if (searchVolume > 200) { ragClass = "ranking-badge-volume--high"; label = "High"; } else if (searchVolume > 50) { ragClass = "ranking-badge-volume--med"; label = "Med"; } volumeBadge.className = `ranking-badge-volume ${ragClass}`; volumeBadge.textContent = `${formatted} ${label}`; } tdVolume.appendChild(volumeBadge); tr.appendChild(tdVolume); // CTR (30d) - query-only from queryTotals const tdCtr = document.createElement("td"); const queryTotal = getQueryTotalForKeyword(row.keyword); if (queryTotal && queryTotal.impressions > 0 && queryTotal.ctr != null) { // queryTotal.ctr is already a percentage (0-100) from API, not a decimal const ctrPercent = queryTotal.ctr.toFixed(1); tdCtr.textContent = `${ctrPercent}%`; tdCtr.style.color = '#1e293b'; tdCtr.title = `Google Search Console. Query-only (keyword totals across all pages). Last 28 days (matches GSC UI).`; } else { tdCtr.textContent = "—"; tdCtr.style.color = '#94a3b8'; tdCtr.title = `No query totals returned for this keyword in the last 28 days.`; } tr.appendChild(tdCtr); // Impressions (28d) - query-only from queryTotals const tdImpressions = document.createElement("td"); if (queryTotal && queryTotal.impressions != null && queryTotal.impressions > 0) { tdImpressions.textContent = queryTotal.impressions.toLocaleString(); tdImpressions.style.color = '#1e293b'; tdImpressions.title = `Google Search Console. Query-only (keyword totals across all pages). Last 28 days (matches GSC UI).`; } else { tdImpressions.textContent = "—"; tdImpressions.style.color = '#94a3b8'; tdImpressions.title = `No query totals returned for this keyword in the last 28 days.`; } tr.appendChild(tdImpressions); // Opportunity Score const tdOpportunity = document.createElement("td"); const oppScore = row.opportunityScore ?? null; if (oppScore != null) { const oppBadge = document.createElement("span"); let oppClass = "ranking-badge-opportunity--low"; if (oppScore >= 70) { oppClass = "ranking-badge-opportunity--high"; } else if (oppScore >= 40) { oppClass = "ranking-badge-opportunity--medium"; } oppBadge.className = `ranking-badge-opportunity ${oppClass}`; oppBadge.textContent = `${oppScore}/100`; tdOpportunity.appendChild(oppBadge); } else { tdOpportunity.textContent = "—"; } tr.appendChild(tdOpportunity); // AI Overview const tdAi = document.createElement("td"); const aiBadge = document.createElement("span"); aiBadge.className = "ranking-badge " + (row.has_ai_overview ? "ranking-badge--ai-on" : "ranking-badge--ai-off"); aiBadge.textContent = row.has_ai_overview ? "On" : "Off"; tdAi.appendChild(aiBadge); tr.appendChild(tdAi); // AI citation with RAG badge const tdCitation = document.createElement("td"); const citBadge = document.createElement("span"); const isCited = row.ai_alan_citations_count > 0; const totalCits = row.ai_total_citations || 0; const label = isCited ? `${row.ai_alan_citations_count}/${totalCits || "?"}` : "0"; citBadge.className = isCited ? "ranking-badge-citation ranking-badge-citation--good" : "ranking-badge-citation ranking-badge-citation--weak"; citBadge.textContent = label; tdCitation.appendChild(citBadge); tr.appendChild(tdCitation); // Classic Ranking URL const tdUrl = document.createElement("td"); if (row.best_url) { const a = document.createElement("a"); a.href = row.targetUrl || row.ranking_url || row.best_url || ''; a.target = "_blank"; a.rel = "noopener noreferrer"; a.textContent = row.best_title || row.best_url; tdUrl.appendChild(a); } else { tdUrl.textContent = "—"; } tr.appendChild(tdUrl); // Page type const tdType = document.createElement("td"); const pageType = row.pageType || "Landing"; const typeBadge = document.createElement("span"); // Use similar styling to Money Pages matrix let typeClass = "ranking-badge-page-type"; if (pageType === "Event") typeClass += " ranking-badge-page-type--event"; else if (pageType === "Product") typeClass += " ranking-badge-page-type--product"; else if (pageType === "Blog") typeClass += " ranking-badge-page-type--blog"; else if (pageType === "GBP") typeClass += " ranking-badge-page-type--gbp"; else typeClass += " ranking-badge-page-type--landing"; typeBadge.className = typeClass; typeBadge.textContent = pageType; tdType.appendChild(typeBadge); tr.appendChild(tdType); // Optimisation column const tdOptimisation = document.createElement("td"); tdOptimisation.style.position = "relative"; // Ranking & AI tasks are keyword-level tasks, use 'content' task type // (not 'on_page' which is for page-level tasks without keywords) const taskType = 'content'; const status = window.getOptimisationStatus(row, taskType); // Safety check: ensure status object has expected structure if (!status || typeof status !== 'object' || !status.status) { // Not tracked - show "Not tracked" pill + Track button const notTrackedBadge = document.createElement("span"); notTrackedBadge.className = "ranking-badge ranking-badge--segment-general"; notTrackedBadge.style.marginRight = "0.5rem"; notTrackedBadge.textContent = "Not tracked"; notTrackedBadge.title = "No optimisation task exists for this keyword + URL."; tdOptimisation.appendChild(notTrackedBadge); const trackBtn = document.createElement("button"); trackBtn.className = "btn btn-small"; trackBtn.style.padding = "0.15rem 0.4rem"; trackBtn.style.fontSize = "0.3rem"; trackBtn.style.fontWeight = "550"; trackBtn.style.background = "#2563eb"; trackBtn.style.color = "#ffffff"; trackBtn.style.border = "1px solid #1e40af"; trackBtn.textContent = "Track"; trackBtn.title = "Create a task for this keyword + URL and capture baseline metrics."; if (window.isShareMode) { trackBtn.disabled = true; trackBtn.title = "Not available in share mode (read-only)"; trackBtn.style.opacity = "0.5"; trackBtn.style.cursor = "not-allowed"; } else if (!window.hasAdminKey()) { trackBtn.disabled = true; trackBtn.title = "Admin key required - set your admin key in the configuration section"; trackBtn.style.opacity = "0.5"; trackBtn.style.cursor = "not-allowed"; } trackBtn.onclick = (e) => { e.stopPropagation(); if (window.isShareMode) { alert('Write operations are not available in share mode (read-only).'); return; } if (!window.hasAdminKey()) { alert('Admin key required. Please set your admin key in the "Optimisation Tracking Security" section.'); return; } window.openTrackKeywordModal(row, taskType); }; tdOptimisation.appendChild(trackBtn); } else { // Tracked - check if status is 'done' or 'cancelled', show Track again button // Note: 'deleted' should never appear - if task is deleted, it won't exist in status map if (status.status === 'done' || status.status === 'cancelled') { // Show status pill + Track again button const statusBadge = document.createElement("span"); statusBadge.className = "ranking-badge ranking-badge--segment-general"; statusBadge.style.marginRight = "0.5rem"; const statusLabels = { 'done': 'Done', 'cancelled': 'Cancelled' }; statusBadge.textContent = statusLabels[status.status] || status.status; const statusTooltips = { 'done': 'Cycle completed; start a new cycle if optimising again.', 'cancelled': 'Tracking stopped; you can start a new cycle later.' }; statusBadge.title = statusTooltips[status.status] || 'Click Track again to start a new cycle.'; tdOptimisation.appendChild(statusBadge); const trackBtn = document.createElement("button"); trackBtn.className = "btn btn-small"; trackBtn.style.padding = "0.03rem 0.08rem"; trackBtn.style.fontSize = "0.35rem"; trackBtn.style.fontWeight = "550"; trackBtn.style.background = "#2563eb"; trackBtn.style.color = "#ffffff"; trackBtn.style.border = "1px solid #1e40af"; trackBtn.textContent = "Track again"; trackBtn.title = "Start a new cycle for this keyword + URL."; if (window.isShareMode) { trackBtn.disabled = true; trackBtn.title = "Not available in share mode (read-only)"; trackBtn.style.opacity = "0.5"; trackBtn.style.cursor = "not-allowed"; } else if (!window.hasAdminKey()) { trackBtn.disabled = true; trackBtn.title = "Admin key required - set your admin key in the configuration section"; trackBtn.style.opacity = "0.5"; trackBtn.style.cursor = "not-allowed"; } trackBtn.onclick = (e) => { e.stopPropagation(); if (window.isShareMode) { alert('Write operations are not available in share mode (read-only).'); return; } if (!window.hasAdminKey()) { alert('Admin key required. Please set your admin key in the "Optimisation Tracking Security" section.'); return; } window.openTrackKeywordModal(row, taskType); }; tdOptimisation.appendChild(trackBtn); tr.appendChild(tdOptimisation); tbody.appendChild(tr); return; // Exit early, don't show Manage button } // Handle 'deleted' status gracefully (shouldn't happen with hard delete, but safety check) if (status.status === 'deleted') { // Show "Not tracked" + Track button const notTrackedBadge = document.createElement("span"); notTrackedBadge.className = "ranking-badge ranking-badge--segment-general"; notTrackedBadge.style.marginRight = "0.5rem"; notTrackedBadge.textContent = "Not tracked"; notTrackedBadge.title = "No optimisation task exists for this keyword + URL."; tdOptimisation.appendChild(notTrackedBadge); const trackBtn = document.createElement("button"); trackBtn.className = "btn btn-small"; trackBtn.style.padding = "0.03rem 0.08rem"; trackBtn.style.fontSize = "0.35rem"; trackBtn.style.fontWeight = "550"; trackBtn.style.background = "#2563eb"; trackBtn.style.color = "#ffffff"; trackBtn.style.border = "1px solid #1e40af"; trackBtn.textContent = "Track"; trackBtn.title = "Create a task for this keyword + URL and capture baseline metrics."; if (!window.hasAdminKey()) { trackBtn.disabled = true; trackBtn.title = "Admin key required - set your admin key in the configuration section"; trackBtn.style.opacity = "0.5"; trackBtn.style.cursor = "not-allowed"; } trackBtn.onclick = (e) => { e.stopPropagation(); if (!window.hasAdminKey()) { alert('Admin key required. Please set your admin key in the "Optimisation Tracking Security" section.'); return; } window.openTrackKeywordModal(row, taskType); }; tdOptimisation.appendChild(trackBtn); tr.appendChild(tdOptimisation); tbody.appendChild(tr); return; // Exit early } // Tracked - show status pill + metadata + Manage button console.log('[Optimisation] Creating Manage button for tracked keyword:', row.keyword, 'Status:', status); const statusBadge = document.createElement("span"); const statusText = { 'planned': 'Planned', 'in_progress': 'In progress', 'monitoring': 'Monitoring', 'done': 'Done', 'paused': 'Paused', 'cancelled': 'Cancelled' }[status.status] || status.status; // Distinct color scheme for optimization statuses (different from page type badges) statusBadge.className = "ranking-badge"; // Apply status-specific colors if (status.status === 'planned') { // Light lavender - queued/upcoming statusBadge.style.background = "#e9d5ff"; statusBadge.style.color = "#6b21a8"; } else if (status.status === 'in_progress') { // Orange - active work statusBadge.style.background = "#fed7aa"; statusBadge.style.color = "#9a3412"; } else if (status.status === 'monitoring') { // Teal - watching/observing statusBadge.style.background = "#a7f3d0"; statusBadge.style.color = "#065f46"; } else if (status.status === 'done') { // Green - completed successfully statusBadge.style.background = "#dcfce7"; statusBadge.style.color = "#166534"; } else if (status.status === 'paused') { // Grey - on hold statusBadge.style.background = "#e5e7eb"; statusBadge.style.color = "#374151"; } else if (status.status === 'cancelled') { // Red - stopped/error statusBadge.style.background = "#fee2e2"; statusBadge.style.color = "#991b1b"; } else { // Default grey statusBadge.style.background = "#f9fafb"; statusBadge.style.color = "#4b5563"; } statusBadge.textContent = statusText; statusBadge.style.marginRight = "0.5rem"; // Add tooltip based on status const statusTooltips = { 'planned': 'Task created but work not started.', 'in_progress': 'Currently being worked on.', 'monitoring': 'Changes shipped—monitor metrics.', 'done': 'Cycle completed; start a new cycle if optimising again.', 'paused': 'Tracking stopped; you can start a new cycle later.', 'cancelled': 'Tracking stopped; you can start a new cycle later.' }; statusBadge.title = statusTooltips[status.status] || `Status: ${statusText}`; tdOptimisation.appendChild(statusBadge); // Metadata (cycle + last activity) const metadataDiv = document.createElement("div"); metadataDiv.style.fontSize = "0.7rem"; metadataDiv.style.color = "#64748b"; metadataDiv.style.marginTop = "0.25rem"; metadataDiv.style.marginBottom = "0.25rem"; const cycleText = document.createElement("span"); cycleText.textContent = `Cycle ${status.cycle_active || 1}`; cycleText.style.marginRight = "0.5rem"; metadataDiv.appendChild(cycleText); if (status.last_activity_at) { const lastActivityText = document.createElement("span"); const lastActivity = new Date(status.last_activity_at); const now = new Date(); const daysAgo = Math.floor((now - lastActivity) / (1000 * 60 * 60 * 24)); lastActivityText.textContent = daysAgo === 0 ? "Today" : daysAgo === 1 ? "1 day ago" : `${daysAgo} days ago`; metadataDiv.appendChild(lastActivityText); } tdOptimisation.appendChild(metadataDiv); // Manage button const manageBtn = document.createElement("button"); manageBtn.className = "btn btn-small"; manageBtn.style.padding = "0.15rem 0.4rem"; manageBtn.style.fontSize = "0.3rem"; manageBtn.style.fontWeight = "550"; manageBtn.style.background = "#E5FFCC"; manageBtn.style.color = "#000000"; manageBtn.style.border = "1px solid #a3d977"; manageBtn.textContent = "Manage"; manageBtn.title = "Open task details, update status, add notes, record measurements, start new cycle."; manageBtn.style.position = "relative"; manageBtn.style.zIndex = "100"; manageBtn.style.pointerEvents = "auto"; manageBtn.type = "button"; // Prevent form submission // Store status and row data on button for access in handler (closure safety) const statusId = status.id; const statusObj = status; const rowData = row; const taskTypeValue = taskType; console.log('[Optimisation] Setting up Manage button onclick, statusId:', statusId); if (!window.hasAdminKey()) { manageBtn.disabled = true; manageBtn.title = "Admin key required - set your admin key in the configuration section"; manageBtn.style.opacity = "0.5"; manageBtn.style.cursor = "not-allowed"; } // Use onclick - simpler and more reliable manageBtn.onclick = async function(e) { e.stopPropagation(); e.preventDefault(); if (!window.hasAdminKey()) { alert('Admin key required. Please set your admin key in the "Optimisation Tracking Security" section.'); return; } if (!statusId) { alert('No task ID found. Please try refreshing the page.'); return; } // Open the drawer with the status ID // The drawer function will handle loading tasks if needed and switching tabs if (typeof window.openOptimisationTaskDrawer === 'function') { try { await window.openOptimisationTaskDrawer(statusId); } catch (error) { console.error('[Optimisation] Error opening drawer:', error); // Fallback to modal window.openManageOptimisationModal(rowData, statusObj, taskTypeValue); } } else { // Fallback to modal if drawer function doesn't exist window.openManageOptimisationModal(rowData, statusObj, taskTypeValue); } }; tdOptimisation.appendChild(manageBtn); } tr.appendChild(tdOptimisation); tbody.appendChild(tr); } catch (error) { console.error(`[Ranking Table] Error rendering row ${index} for keyword "${row?.keyword || 'unknown'}":`, error); // Continue to next row instead of stopping the loop } }); // Update sort indicators document.querySelectorAll('.ranking-table th.sortable').forEach(th => { th.classList.remove('sort-asc', 'sort-desc'); if (th.dataset.sort === rankingSortState.column) { th.classList.add(`sort-${rankingSortState.direction}`); } }); // Update pagination controls updatePaginationControls(totalRows, currentPage, totalPages, startIdx, endIdx); // Re-wire sorting after render completes wireRankingSorting(); // Row click → scorecard panel tbody.querySelectorAll("tr").forEach(tr => { tr.addEventListener("click", async () => { tbody.querySelectorAll("tr").forEach(r => r.classList.remove("ranking-table-row--selected")); tr.classList.add("ranking-table-row--selected"); const idx = Number(tr.dataset.index || "-1"); if (!Number.isNaN(idx) && combinedRows[idx]) { const row = combinedRows[idx]; // Create unique identifier for selected keyword selectedKeywordId = `${row.keyword}|${row.best_url || ''}`; debugLog(`[RankingAI Scorecard] Row clicked - keyword: "${row.keyword}", selectedKeywordId: "${selectedKeywordId}"`, 'info'); await renderKeywordScorecard(row); } }); }); // Competitors (use filtered rows) renderRankingAiCompetitors(filteredRows); // Insights (use all rows for global insights across all tracked keywords) renderRankingAiInsights(combinedRows, summary); } function updatePaginationControls(totalRows, currentPage, totalPages, startIdx, endIdx) { const paginationControls = document.getElementById("ranking-pagination-controls"); const paginationInfo = document.getElementById("ranking-pagination-info"); const pageInfo = document.getElementById("ranking-pagination-page-info"); const firstBtn = document.getElementById("ranking-pagination-first"); const prevBtn = document.getElementById("ranking-pagination-prev"); const nextBtn = document.getElementById("ranking-pagination-next"); const lastBtn = document.getElementById("ranking-pagination-last"); const rowsPerPageSelect = document.getElementById("ranking-rows-per-page"); if (!paginationControls) return; if (totalRows === 0) { paginationControls.style.display = "none"; return; } paginationControls.style.display = "flex"; if (paginationInfo) { paginationInfo.textContent = `Showing ${startIdx + 1}-${endIdx} of ${totalRows}`; } if (pageInfo) { pageInfo.textContent = `Page ${currentPage} of ${totalPages}`; } if (firstBtn) { firstBtn.disabled = currentPage === 1; } if (prevBtn) { prevBtn.disabled = currentPage === 1; } if (nextBtn) { nextBtn.disabled = currentPage === totalPages; } if (lastBtn) { lastBtn.disabled = currentPage === totalPages; } if (rowsPerPageSelect) { rowsPerPageSelect.value = rankingPaginationState.rowsPerPage; } } /** * Render Keyword Scorecard Panel * @param {Object} row - Keyword row from combinedRows */ async function renderKeywordScorecard(row) { const emptyEl = document.getElementById("ranking-ai-detail-empty"); const contentEl = document.getElementById("ranking-ai-detail-content"); if (!emptyEl || !contentEl) return; if (!row) { emptyEl.hidden = false; contentEl.hidden = true; emptyEl.textContent = "Select a keyword in the table or a cell in the priority grid to see a detailed scorecard."; // Hide citations section const citationsEmpty = document.getElementById("ranking-ai-citations-empty"); const citationsContent = document.getElementById("ranking-ai-citations-content"); if (citationsEmpty) citationsEmpty.hidden = false; if (citationsContent) citationsContent.hidden = true; return; } const scorecardData = buildKeywordScorecardData(row); if (!scorecardData) { emptyEl.hidden = false; contentEl.hidden = true; return; } // Fetch authority context for authority-building block (v1.4) const authorityContext = await fetchRankingAiAuthorityContext(); const authorityPriority = authorityContext?.authorityPriority ?? null; const domainStrength = authorityContext?.domainStrength ?? null; debugLog(`[RankingAI Scorecard] Rendering scorecard for keyword: "${scorecardData.keyword}"`, 'info'); debugLog(`[RankingAI Scorecard] Demand: ${scorecardData.demand_level}, Rank: ${scorecardData.rank_bucket_label}, AI: ${scorecardData.ai_status}, Priority: ${scorecardData.priority_level}`, 'info'); emptyEl.hidden = true; contentEl.hidden = false; // Apply RAG color class to content element based on priority contentEl.classList.remove('scorecard-priority-high', 'scorecard-priority-medium', 'scorecard-priority-low'); if (scorecardData.priority_level === 'High') { contentEl.classList.add('scorecard-priority-high'); } else if (scorecardData.priority_level === 'Medium') { contentEl.classList.add('scorecard-priority-medium'); } else { contentEl.classList.add('scorecard-priority-low'); } // Build HTML for scorecard let html = ''; // Impact & Difficulty summary line (under card header) - RAG color-coded const impactLabel = scorecardData.impact_bucket.charAt(0).toUpperCase() + scorecardData.impact_bucket.slice(1); const difficultyLabel = scorecardData.difficulty_bucket.charAt(0).toUpperCase() + scorecardData.difficulty_bucket.slice(1); const priorityLabel = scorecardData.priority_level; // RAG colors for each level const getRagColor = (level) => { const lower = level.toLowerCase(); if (lower === 'high') return '#ef4444'; // Red if (lower === 'medium') return '#f59e0b'; // Amber return '#10b981'; // Green (Low) }; const impactColor = getRagColor(impactLabel); const difficultyColor = getRagColor(difficultyLabel); const priorityColor = getRagColor(priorityLabel); // Opportunity Score (under header title, in same section as Impact/Difficulty/Priority) const oppScore = scorecardData.opportunity_score ?? null; let oppColor = '#b91c1c'; // Red (Low) let oppClass = 'low'; if (oppScore != null) { if (oppScore >= 70) { oppColor = '#166534'; // Green (High) oppClass = 'high'; } else if (oppScore >= 40) { oppColor = '#92400e'; // Amber (Medium) oppClass = 'medium'; } } html += `
    `; if (oppScore != null) { html += `

    `; html += `Keyword opportunity score: ${oppScore}/100`; html += ` `; html += `

    `; } html += `

    `; html += `Impact: ${impactLabel} • `; html += `Difficulty: ${difficultyLabel} • `; html += `Priority: ${priorityLabel}`; html += `

    `; // Add derived summary sentence const summarySentence = generateKeywordSummary(scorecardData); html += `

    ${summarySentence}

    `; html += `
    `; // Keyword & URL header - Keyword as prominent nameplate html += `
    `; // Keyword nameplate - large and prominent html += `
    `; html += `

    ${scorecardData.keyword}

    `; html += `
    `; // Use canonical targetUrl for display const displayUrl = scorecardData.targetUrl || scorecardData.ranking_url || ''; if (displayUrl) { html += `

    ${displayUrl}

    `; } html += `
    `; html += `${scorecardData.segment || 'Other'}`; html += `${scorecardData.page_type}`; html += `
    `; // Grey meta line (under chips) const metaParts = []; if (row.best_rank_group != null) { metaParts.push(`Classic rank: #${row.best_rank_group}`); } else { metaParts.push("Classic rank: not in top 50"); } if (row.has_ai_overview) { const total = row.ai_total_citations || 0; const ours = row.ai_alan_citations_count || 0; metaParts.push(`AI Overview: present (${ours}/${total || "?"} citations from alanranger.com)`); } else { metaParts.push("AI Overview: not present"); } const segLabel = row.segment === "money" ? "Money page (commercial intent)" : row.segment === "education" ? "Education content" : row.segment === "brand" ? "Brand query" : "General / other"; metaParts.push(`Segment: ${segLabel}`); html += `

    ${metaParts.join(" • ")}

    `; html += `
    `; // Priority & Next Actions (moved to top, before Demand and Classic ranking) html += `
    `; html += `
    Priority & Next Actions
    `; const priorityBg = scorecardData.priority_level === 'High' ? '#fef2f2' : scorecardData.priority_level === 'Medium' ? '#fffbeb' : '#f0fdf4'; const priorityBorder = scorecardData.priority_level === 'High' ? '#ef4444' : scorecardData.priority_level === 'Medium' ? '#f59e0b' : '#10b981'; html += `

    Overall priority: ${scorecardData.priority_level}

    `; // Authority-building block (v1.4: Domain Strength integration) const opportunityScore = scorecardData.opportunity_score ?? 0; const currentRank = scorecardData.best_rank_group ?? null; const isAuthorityLimited = authorityPriority === 'high' && opportunityScore >= 60 && (currentRank === null || currentRank > 10); if (isAuthorityLimited) { html += `
    `; html += `
    Authority & external signals
    `; html += `
      `; const domainStrengthScore = domainStrength?.score ?? null; const scoreText = domainStrengthScore !== null ? `~${domainStrengthScore.toFixed(1)}` : 'unknown'; const bandText = domainStrength?.band ?? 'unknown'; html += `
    • Overall domain strength is currently ${bandText} (score ${scoreText}).
    • `; html += `
    • For this high-impact keyword on page 2+, focus on authority-building: relevant backlinks, citations, PR, and mentions from trusted sites in the same topic.
    • `; html += `
    `; html += `
    `; } else if (authorityPriority === 'medium' && opportunityScore >= 60 && (currentRank === null || currentRank > 10)) { // Medium priority - softer wording html += `
    `; html += `
    Authority & external signals
    `; html += `
      `; const domainStrengthScore = domainStrength?.score ?? null; const scoreText = domainStrengthScore !== null ? `~${domainStrengthScore.toFixed(1)}` : 'unknown'; html += `
    • Domain strength is moderate (score ${scoreText}). Also consider authority-building alongside on-page improvements for this high-impact keyword.
    • `; html += `
    `; html += `
    `; } const actions = generateActionBullets(scorecardData); // Always show 3 bullets (function now guarantees 3) html += `
      `; actions.forEach(action => { html += `
    • ${action}
    • `; }); html += `
    `; html += `
    `; // 1. Target page totals and 2. Classic ranking - side by side with equal height/width/padding html += `
    `; // 1. Target page totals (left half) - page-only GSC data html += `
    `; html += `
    1. Target page totals (GSC, 28d)
    `; // Display target page URL (clickable) - use canonical targetUrl const canonicalUrl = scorecardData.targetUrl || scorecardData.ranking_url || ''; if (canonicalUrl) { // URL is already canonicalized, just use it directly const cleanUrl = canonicalUrl; html += `
    `; html += `
    Target page (all queries):
    `; html += `${cleanUrl}`; html += `
    ℹ Totals for this page across all search queries (not just this keyword).
    `; html += `
    `; } html += `
    `; html += `
    Loading page totals...
    `; html += `
    `; // 2. Classic ranking (right half) html += `
    `; html += `
    2. Classic ranking
    `; html += `
    `; let rankText = ''; if (scorecardData.best_rank_group != null) { const rankBucket = scorecardData.rank_bucket || scorecardData.rank_bucket_label || ''; // Format: "Currently ranking #24 (page 2+ / Not ranked)" if (rankBucket === 'top3' || rankBucket === 'page 1') { rankText = `Currently ranking #${scorecardData.best_rank_group} (page 1)`; } else if (rankBucket === 'top10' || rankBucket === 'page 1') { rankText = `Currently ranking #${scorecardData.best_rank_group} (page 1)`; } else if (rankBucket === 'page2plus' || rankBucket === 'beyond page 2') { rankText = `Currently ranking #${scorecardData.best_rank_group} (page 2+)`; } else { rankText = `Currently ranking #${scorecardData.best_rank_group} (${rankBucket || 'beyond page 2'})`; } } else { rankText = 'Currently ranking (beyond page 2 / Not ranked)'; } html += `
    ${rankText}
    `; const strengthClass = scorecardData.position_strength === 'Strong' ? 'status-green' : scorecardData.position_strength === 'OK' ? 'status-amber' : 'status-red'; const strengthBg = scorecardData.position_strength === 'Strong' ? '#f0fdf4' : scorecardData.position_strength === 'OK' ? '#fffbeb' : '#fef2f2'; const strengthBorder = scorecardData.position_strength === 'Strong' ? '#10b981' : scorecardData.position_strength === 'OK' ? '#f59e0b' : '#ef4444'; html += `
    Position strength: ${scorecardData.position_strength}
    `; html += `
    `; html += `
    `; // Close flex container // 3. CTR & snippet (query-only) html += `
    `; html += `
    3. CTR & snippet (GSC, 28d)
    `; html += `
    `; // Get query-only totals from queryTotals const queryTotal = getQueryTotalForKeyword(scorecardData.keyword); // Only show CTR if impressions > 0 (CTR requires impressions) if (queryTotal && queryTotal.impressions > 0 && queryTotal.ctr != null) { // Real CTR data available (query-only) // queryTotal.ctr is already a percentage (0-100) from API, not a decimal const ctrPercent = queryTotal.ctr.toFixed(1); const impressionsFormatted = queryTotal.impressions.toLocaleString(); const clicksFormatted = queryTotal.clicks.toLocaleString(); const positionBucket = getPositionBucket(scorecardData.best_rank_group); const ctrBenchmark = getCtrBenchmarkForPosition(positionBucket); const benchmarkPercent = (ctrBenchmark * 100).toFixed(1); // Determine CTR performance label let ctrPerformance = 'OK'; let ctrPerformanceColor = '#f59e0b'; // Amber let ctrPerformanceBg = '#fffbeb'; if (queryTotal.ctr >= ctrBenchmark * 1.1) { ctrPerformance = 'Strong'; ctrPerformanceColor = '#10b981'; // Green ctrPerformanceBg = '#f0fdf4'; } else if (queryTotal.ctr < ctrBenchmark * 0.8) { ctrPerformance = 'Weak'; ctrPerformanceColor = '#ef4444'; // Red ctrPerformanceBg = '#fef2f2'; } html += `
    `; html += `
    `; html += `CTR (last 28 days): ${ctrPercent}%`; html += `·`; html += `Impressions: ${impressionsFormatted}`; html += `·`; html += `Clicks: ${clicksFormatted}`; html += `(query-only)`; html += `
    `; html += `
    `; html += `Expected CTR at this position: ${benchmarkPercent}% `; html += `
    `; html += `
    `; html += `CTR performance: ${ctrPerformance}`; html += `
    `; html += `
    `; // Explanatory text based on performance if (ctrPerformance === 'Weak') { html += `

    CTR is weak for this position. Improving the snippet (title, meta description and rich results) should unlock more clicks for this keyword.

    `; } else if (ctrPerformance === 'Strong') { html += `

    CTR is in line with expectations for this position. Further gains are more likely to come from improving rank (links and authority) than snippet tweaks alone.

    `; } else { // OK performance html += `

    CTR is in line with expectations for this position. Further gains are more likely to come from improving rank (links and authority) than snippet tweaks alone.

    `; } } else { // No query-only data found for last 28 days html += `

    No query-only totals returned for this keyword in the last 28 days.

    `; } html += `
    `; // Advanced: Pages with impressions for this keyword (query→pages breakdown) html += `
    `; html += `
    Advanced: Pages with impressions for this keyword (GSC)
    `; html += `
    `; html += `
    Loading pages breakdown...
    `; html += `
    `; // 4. Schema & rich results html += `
    `; html += `
    4. Schema & rich results
    `; html += `
    `; // Get schema coverage if available // Try to use the async getSchemaCoverageForUrl function which handles Supabase fallback let schemaCoverage = null; let schemaSummary = null; try { // First try sync (localStorage) for immediate display const savedAudit = loadAuditResultsSync(); if (savedAudit && savedAudit.schemaAudit && savedAudit.schemaAudit.data) { const schemaData = savedAudit.schemaAudit.data; // Lightweight debug for the object used in this card try { const pagesWithSchemaCount = Array.isArray(schemaData.pagesWithSchema) ? schemaData.pagesWithSchema.length : (typeof schemaData.pagesWithSchema === 'number' ? schemaData.pagesWithSchema : 0); const totalPagesCount = typeof schemaData.totalPages === 'number' ? schemaData.totalPages : (Array.isArray(schemaData.pages) ? schemaData.pages.length : 0); const coveragePct = (typeof schemaData.coverage === 'number' && !Number.isNaN(schemaData.coverage)) ? schemaData.coverage : (totalPagesCount > 0 ? (pagesWithSchemaCount / totalPagesCount) * 100 : 0); const allTypes = new Set(); if (Array.isArray(schemaData.allDetectedTypes)) { schemaData.allDetectedTypes.forEach(t => t && allTypes.add(t)); } else if (Array.isArray(schemaData.schemaTypes)) { schemaData.schemaTypes.forEach(item => { if (typeof item === 'string') allTypes.add(item); else if (item && typeof item === 'object' && item.type) allTypes.add(item.type); }); } schemaSummary = { pagesWithSchemaCount, totalPagesCount, coveragePct, uniqueTypesCount: allTypes.size }; debugLog( `Ranking&AI schemaAuditForCard: totalPages=${totalPagesCount}, pagesWithSchema=${pagesWithSchemaCount}, coverage=${coveragePct.toFixed(1)}%, uniqueTypes=${allTypes.size}`, 'info' ); } catch (e) { // Ignore summary extraction errors; per-page logic may still work } // PRIORITY: Check pages first (original array from schema audit API - most reliable) // Then check pagesWithSchema ONLY if it's an array (from Supabase schema_pages_detail) // Note: pagesWithSchema might be a number (count) instead of an array let pagesArray = null; if (Array.isArray(schemaData.pages) && schemaData.pages.length > 0) { pagesArray = schemaData.pages; debugLog('[Schema Coverage] Using schemaData.pages array (' + pagesArray.length + ' pages)', 'info'); } else if (Array.isArray(schemaData.pagesWithSchema) && schemaData.pagesWithSchema.length > 0) { pagesArray = schemaData.pagesWithSchema; debugLog('[Schema Coverage] Using schemaData.pagesWithSchema array from Supabase (' + pagesArray.length + ' pages)', 'info'); } else { debugLog('[Schema Coverage] No pages array found. pages=' + typeof schemaData.pages + ' (isArray=' + Array.isArray(schemaData.pages) + '), pagesWithSchema=' + typeof schemaData.pagesWithSchema + ' (isArray=' + Array.isArray(schemaData.pagesWithSchema) + ')', 'warn'); } if (pagesArray && pagesArray.length > 0) { // Normalize URL: remove query params and trailing slashes for matching const rankingUrl = scorecardData.ranking_url || ''; const normalizedUrl = normalizeUrlForMatching(rankingUrl); debugLog('[Schema Coverage] Looking for URL: ' + normalizedUrl + ' (original: ' + rankingUrl + ')', 'info'); // Fallback matching: some ranking URLs include redirect/variant slugs (e.g. "121" vs "1-2-1") // If exact pathname match fails, compare a "loose" slug that strips non-alphanumerics. const normalizeLooseSlug = (pathname) => { const p = (pathname || '').toString().toLowerCase().trim(); const last = p.split('/').filter(Boolean).pop() || ''; return last.replace(/[^a-z0-9]/g, ''); }; const normalizedLoose = normalizeLooseSlug(normalizedUrl); // Exact matching only - normalize both URLs identically (strips query params, hash, trailing slashes) let looseMatchedUrl = null; const pageData = pagesArray.find(p => { if (!p || !p.url) return false; const pNormalized = normalizeUrlForMatching(p.url); // Exact match after normalization (both URLs stripped of query params, hash, trailing slashes) const exactMatch = pNormalized === normalizedUrl; // For homepage, also check if both are '/' or empty const homepageMatch = (normalizedUrl === '/' || normalizedUrl === '') && (pNormalized === '/' || pNormalized === ''); if (exactMatch || homepageMatch) { debugLog('[Schema Coverage] ✅ Exact URL match: ' + p.url + ' -> ' + pNormalized + ' (search: ' + normalizedUrl + ')', 'info'); return true; } // Fallback: loose slug match (handles minor slug variants) if (!exactMatch && normalizedLoose) { const pLoose = normalizeLooseSlug(pNormalized); if (pLoose && pLoose === normalizedLoose) { looseMatchedUrl = p.url; debugLog('[Schema Coverage] ⚠ Loose slug match: ' + p.url + ' -> ' + pNormalized + ' (search: ' + normalizedUrl + ')', 'warn'); return true; } } return false; }); if (pageData) { debugLog('[Schema Coverage] ✅ Found page data for URL: ' + scorecardData.ranking_url, 'info'); debugLog('[Schema Coverage] Matched page URL: ' + (pageData.url || 'missing'), 'info'); if (looseMatchedUrl) { debugLog('[Schema Coverage] Note: match used loose slug fallback (ranking URL likely redirected or variant slug).', 'warn'); } if (pageData.schemaTypes) { const schemaTypes = Array.isArray(pageData.schemaTypes) ? pageData.schemaTypes : []; const typeStrings = schemaTypes.map(t => { if (typeof t === 'string') return t.toLowerCase(); if (t && typeof t === 'object' && t.type) return String(t.type).toLowerCase(); return String(t).toLowerCase(); }); const typeDisplay = schemaTypes.map(t => { if (typeof t === 'string') return t; if (t && typeof t === 'object' && t.type) return t.type; return String(t); }).join(', '); debugLog('[Schema Coverage] Schema types found (' + schemaTypes.length + '): ' + typeDisplay, 'info'); schemaCoverage = { hasFAQ: typeStrings.some(t => t.includes('faq') || t === 'faqpage'), hasHowTo: typeStrings.some(t => t.includes('howto') || t === 'howto'), hasEvent: typeStrings.some(t => t.includes('event') && !t.includes('product')), hasProduct: typeStrings.some(t => t.includes('product')), hasBreadcrumb: typeStrings.some(t => t.includes('breadcrumb') || t === 'breadcrumblist'), hasImageObject: typeStrings.some(t => t.includes('image') || t === 'imageobject') }; debugLog('[Schema Coverage] Coverage result: ' + JSON.stringify(schemaCoverage), 'info'); } else { debugLog('[Schema Coverage] ⚠️ Page data found but no schemaTypes property', 'warn'); debugLog('[Schema Coverage] Page data keys: ' + Object.keys(pageData).join(', '), 'warn'); } } else { debugLog('[Schema Coverage] ❌ No page data found for URL: ' + scorecardData.ranking_url, 'warn'); debugLog('[Schema Coverage] Normalized search URL: ' + normalizedUrl, 'warn'); if (pagesArray && pagesArray.length > 0) { debugLog('[Schema Coverage] Sample URLs in pages array (first 3):', 'warn'); pagesArray.slice(0, 3).forEach((p, i) => { let pNorm = (p.url || '').toLowerCase().trim(); try { const pUrlObj = p.url ? new URL(p.url) : null; if (pUrlObj) { pNorm = pUrlObj.pathname.toLowerCase().replace(/\/$/, '').trim(); } } catch (e) {} debugLog(` ${i + 1}. ${p.url} -> ${pNorm}`, 'warn'); }); } } } else { debugLog('[Schema Coverage] No pages array available in schemaData', 'warn'); debugLog('[Schema Coverage] This usually means the schema audit hasn\'t been run yet, or the data is missing from localStorage/Supabase.', 'info'); } } else { debugLog('[Schema Coverage] No schemaAudit.data in saved audit, trying async fetch...', 'warn'); } // Always try async fetch to ensure we have the latest schema data // This will update the schema section even if sync data was found if (typeof getSchemaCoverageForUrl === 'function' && scorecardData.ranking_url) { getSchemaCoverageForUrl(scorecardData.ranking_url).then(coverage => { // Re-render just the schema section const schemaSection = contentEl.querySelector('[data-scorecard-section="schema"]'); if (schemaSection) { let schemaHtml = ''; schemaHtml += `
    4. Schema & rich results
    `; schemaHtml += `
    `; if (coverage) { schemaHtml += `
    `; schemaHtml += `
    Schema coverage for this page:
    `; schemaHtml += `
    `; schemaHtml += `FAQ: ${coverage.hasFAQ ? '✅' : '❌'}`; schemaHtml += `HowTo: ${coverage.hasHowTo ? '✅' : '❌'}`; schemaHtml += `Event/Product: ${(coverage.hasEvent || coverage.hasProduct) ? '✅' : '❌'}`; schemaHtml += `Breadcrumb: ${coverage.hasBreadcrumb ? '✅' : '❌'}`; schemaHtml += `ImageObject: ${coverage.hasImageObject ? '✅' : '❌'}`; schemaHtml += `
    `; let schemaInterpretation = ''; if (scorecardData.segment && scorecardData.segment.toLowerCase() === 'education' && !coverage.hasFAQ) { schemaInterpretation = 'This education page has no FAQ schema. Adding an FAQ block could help snippet richness for this keyword.'; } else if ((scorecardData.page_type === 'Event' || scorecardData.page_type === 'Product') && !coverage.hasEvent && !coverage.hasProduct) { schemaInterpretation = 'This looks like a money page but no Event/Product schema was detected. Adding Event or Product schema could improve visibility in commercial results.'; } else if (coverage.hasFAQ && (coverage.hasEvent || coverage.hasProduct) && coverage.hasBreadcrumb) { schemaInterpretation = 'Core schema types are already present for this page. Further gains are more likely to come from authority/behaviour than new schema types.'; } else { schemaInterpretation = 'Some schema types are present. Review the Content/Schema pillar for a complete assessment.'; } schemaHtml += `

    ${schemaInterpretation}

    `; debugLog('[Schema Coverage] ✅ Updated schema section with async data', 'success'); } else { // No coverage data found - show helpful message explaining data coverage gap const rankingUrl = scorecardData.ranking_url || 'unknown'; const normalizedUrl = normalizeUrlForMatching(rankingUrl); debugLog(`[Schema Coverage] ❌ No schema audit data found for ranking URL: ${rankingUrl} (normalized: ${normalizedUrl})`, 'warn'); debugLog(`[Schema Coverage] This usually means the URL wasn't in the last schema crawl. The page may have schema, but it wasn't scanned in the most recent audit.`, 'info'); schemaHtml += `

    Schema coverage data not available for this URL. This usually means the URL wasn't included in the last schema crawl. Check the Content/Schema pillar for a full schema audit, or run a new schema audit to include this page.

    `; } schemaHtml += `
    `; schemaSection.innerHTML = schemaHtml; } }).catch(err => { const rankingUrl = scorecardData.ranking_url || 'unknown'; debugLog(`[Schema Coverage] Async fetch failed for URL ${rankingUrl}: ${err.message}`, 'warn'); debugLog(`[Schema Coverage] This usually means the URL wasn't in the last schema crawl. The page may have schema, but it wasn't scanned in the most recent audit.`, 'info'); // Update the loading message to show error const schemaSection = contentEl.querySelector('[data-scorecard-section="schema"]'); if (schemaSection) { const loadingMsg = schemaSection.querySelector('[id^="schema-coverage-loading-"]'); if (loadingMsg) { loadingMsg.textContent = 'Unable to load schema coverage. This usually means the URL wasn\'t included in the last schema crawl. Check the Content/Schema pillar for a full schema audit, or run a new schema audit to include this page.'; } } }); } } catch (e) { debugLog('[Schema Coverage] Error in sync check: ' + e.message, 'warn'); } if (schemaCoverage) { // Show checklist html += `
    `; html += `
    Schema coverage for this page:
    `; html += `
    `; html += `FAQ: ${schemaCoverage.hasFAQ ? '✅' : '❌'}`; html += `HowTo: ${schemaCoverage.hasHowTo ? '✅' : '❌'}`; html += `Event/Product: ${(schemaCoverage.hasEvent || schemaCoverage.hasProduct) ? '✅' : '❌'}`; html += `Breadcrumb: ${schemaCoverage.hasBreadcrumb ? '✅' : '❌'}`; html += `ImageObject: ${schemaCoverage.hasImageObject ? '✅' : '❌'}`; html += `
    `; html += `
    `; // Interpretation based on schema coverage let schemaInterpretation = ''; if (scorecardData.segment && scorecardData.segment.toLowerCase() === 'education' && !schemaCoverage.hasFAQ) { schemaInterpretation = 'This education page has no FAQ schema. Adding an FAQ block could help snippet richness for this keyword.'; } else if ((scorecardData.page_type === 'Event' || scorecardData.page_type === 'Product') && !schemaCoverage.hasEvent && !schemaCoverage.hasProduct) { schemaInterpretation = 'This looks like a money page but no Event/Product schema was detected. Adding Event or Product schema could improve visibility in commercial results.'; } else if (schemaCoverage.hasFAQ && (schemaCoverage.hasEvent || schemaCoverage.hasProduct) && schemaCoverage.hasBreadcrumb) { schemaInterpretation = 'Core schema types are already present for this page. Further gains are more likely to come from authority/behaviour than new schema types.'; } else { schemaInterpretation = 'Some schema types are present. Review the Content/Schema pillar for a complete assessment.'; } html += `

    ${schemaInterpretation}

    `; } else if (schemaSummary && schemaSummary.totalPagesCount > 0) { // We have schema audit snapshot, but no per-URL schemaTypes mapping available for this keyword URL html += `
    `; html += `
    Site-wide schema snapshot (from latest audit):
    `; html += `
    `; html += `Coverage: ${schemaSummary.coveragePct.toFixed(1)}%`; html += `·`; html += `Pages with schema: ${schemaSummary.pagesWithSchemaCount.toLocaleString()} / ${schemaSummary.totalPagesCount.toLocaleString()}`; if (schemaSummary.uniqueTypesCount > 0) { html += `·`; html += `Types detected: ${schemaSummary.uniqueTypesCount}`; } html += `
    `; html += `
    `; html += `

    Per-page schema types aren’t available for this specific URL in the current snapshot. Run a fresh schema crawl (or include this URL in the crawl set) to get URL-level rich-result flags here.

    `; } else { // Show loading state if async fetch is in progress, otherwise show fallback // The async fetch will update this section if data is found const rankingUrl = scorecardData.ranking_url || 'unknown'; debugLog(`[Schema Coverage] No schema data found in sync check for URL: ${rankingUrl}. Trying async fetch...`, 'info'); html += `

    Checking schema coverage...

    `; } html += `
    `; // 5. AI usage html += `
    `; html += `
    5. AI usage
    `; html += `
    `; // Format AI status with citation details const aiTotal = row.ai_total_citations || 0; const aiOurs = row.ai_alan_citations_count || 0; let aiStatusText = ''; if (row.has_ai_overview) { if (aiOurs > 0) { aiStatusText = `AI Overview present, cited in ${aiOurs}/${aiTotal} citation${aiTotal !== 1 ? 's' : ''}`; if (aiOurs / aiTotal < 0.33) { aiStatusText += ' (light)'; } } else { aiStatusText = 'AI Overview present, not cited'; } } else { aiStatusText = 'AI Overview not present'; } html += `

    ${aiStatusText}

    `; // Show cited pages list if (scorecardData.ai_citations_ours > 0 && scorecardData.ai_alan_citations && scorecardData.ai_alan_citations.length > 0) { html += `
    Your cited pages:
    `; html += ``; } html += `
    `; // 6. SERP features html += `
    `; html += `
    6. SERP features
    `; html += `
    `; // Get SERP feature presence from scorecardData const hasAiOverview = scorecardData.ai_overview_present_any === true; const hasLocalPack = scorecardData.local_pack_present_any === true; const hasPaa = scorecardData.paa_present_any === true; const hasFeaturedSnippet = scorecardData.featured_snippet_present_any === true; // Count features present const featuresPresent = [hasAiOverview, hasLocalPack, hasPaa, hasFeaturedSnippet].filter(Boolean).length; html += `
    `; html += `
    SERP features present for this keyword:
    `; html += `
    `; html += `AI Overview: ${hasAiOverview ? '✅' : '❌'}`; html += `Local pack: ${hasLocalPack ? '✅' : '❌'}`; html += `People Also Ask: ${hasPaa ? '✅' : '❌'}`; html += `Featured snippet: ${hasFeaturedSnippet ? '✅' : '❌'}`; html += `
    `; html += `
    ${featuresPresent}/4 features present
    `; html += `
    `; // Interpretation based on SERP features let serpInterpretation = ''; if (featuresPresent === 4) { serpInterpretation = 'All major SERP features are present for this keyword. This indicates strong competition and multiple opportunities for visibility.'; } else if (featuresPresent >= 2) { serpInterpretation = 'Multiple SERP features are present. Focus on optimizing for the features where you\'re not yet visible.'; } else if (hasAiOverview && !hasLocalPack && !hasPaa && !hasFeaturedSnippet) { serpInterpretation = 'Only AI Overview is present. This keyword may benefit from local optimization (if applicable) or FAQ schema to trigger People Also Ask.'; } else if (hasLocalPack && !hasAiOverview) { serpInterpretation = 'Local pack is present but no AI Overview. This suggests local intent; ensure your local entity is optimized.'; } else { serpInterpretation = 'Few SERP features are present. This keyword may have lower competition or less rich result potential.'; } html += `

    ${serpInterpretation}

    `; html += `
    `; // Summary line is now included in the keyword header section above // Update the content element - everything is now in one container const kwEl = document.getElementById("ranking-ai-detail-keyword"); if (kwEl) kwEl.innerHTML = html; // Hide the separate summary element since it's now included in the main HTML const sumEl = document.getElementById("ranking-ai-detail-summary"); if (sumEl) sumEl.style.display = 'none'; // Fetch page totals and query→pages breakdown asynchronously const pageTotalsEl = document.getElementById(`scorecard-page-totals-${scorecardData.keyword.replace(/[^a-z0-9]/gi, '_')}`); const queryPagesEl = document.getElementById(`scorecard-query-pages-${scorecardData.keyword.replace(/[^a-z0-9]/gi, '_')}`); // Fetch page-only totals for best_url if (pageTotalsEl && scorecardData.ranking_url) { (async () => { try { const savedAudit = loadAuditResultsSync(); const propertyUrl = savedAudit?.propertyUrl || window.lastAuditPropertyUrl || 'https://www.alanranger.com'; // Calculate date range using centralized helper (last 28 days, ending yesterday - matches GSC UI) const { startDate, endDate } = getGscDateRange(GSC_WINDOW_DAYS, 2); // Use canonical targetUrl for GSC page filter const pageUrlForGsc = scorecardData.targetUrl || scorecardData.ranking_url || ''; const response = await fetch(apiUrl(`/api/aigeo/gsc-page-totals?property=${encodeURIComponent(propertyUrl)}&pageUrl=${encodeURIComponent(pageUrlForGsc)}&startDate=${startDate}&endDate=${endDate}`)); if (response.ok) { const data = await response.json(); if (data.status === 'ok' && data.data) { const pageData = data.data; let pageHtml = ''; pageHtml += `
    `; pageHtml += `Clicks: ${pageData.clicks.toLocaleString()}`; pageHtml += `
    `; pageHtml += `
    `; pageHtml += `Impressions: ${pageData.impressions.toLocaleString()}`; pageHtml += `
    `; pageHtml += `
    `; pageHtml += `CTR: ${(pageData.ctr).toFixed(1)}%`; pageHtml += `
    `; pageHtml += `
    `; pageHtml += `Avg position: ${Math.round(pageData.position)}`; pageHtml += `
    `; pageTotalsEl.innerHTML = pageHtml; } else { pageTotalsEl.innerHTML = `
    No page totals returned for this URL in the last 28 days.
    `; } } else { pageTotalsEl.innerHTML = `
    Unable to load page totals.
    `; } } catch (err) { console.error('Error fetching page totals:', err); pageTotalsEl.innerHTML = `
    Error loading page totals: ${err.message}
    `; } })(); } // Fetch query→pages breakdown if (queryPagesEl) { (async () => { try { const savedAudit = loadAuditResultsSync(); const propertyUrl = savedAudit?.propertyUrl || window.lastAuditPropertyUrl || 'https://www.alanranger.com'; // Calculate date range using centralized helper (last 28 days, ending yesterday - matches GSC UI) const { startDate, endDate } = getGscDateRange(GSC_WINDOW_DAYS, 2); const response = await fetch(apiUrl(`/api/aigeo/gsc-query-pages?property=${encodeURIComponent(propertyUrl)}&query=${encodeURIComponent(scorecardData.keyword)}&startDate=${startDate}&endDate=${endDate}`)); if (response.ok) { const data = await response.json(); if (data.status === 'ok' && data.data && data.data.pages) { const pages = data.data.pages; // Use canonical targetUrl for matching const bestUrl = scorecardData.targetUrl || scorecardData.ranking_url || ''; const normalizedBestUrl = bestUrl ? normalizeGscPageUrl(bestUrl) : null; let pagesHtml = ''; if (pages.length === 0) { pagesHtml = `
    No pages found with impressions for this keyword.
    `; } else { // Separate best URL from other pages const bestUrlPage = pages.find(page => { const normalizedPageUrl = normalizeGscPageUrl(page.page); return normalizedBestUrl && normalizedPageUrl === normalizedBestUrl; }); const otherPages = pages.filter(page => { const normalizedPageUrl = normalizeGscPageUrl(page.page); return !normalizedBestUrl || normalizedPageUrl !== normalizedBestUrl; }); const tableId = `query-pages-table-${scorecardData.keyword.replace(/[^a-z0-9]/gi, '_')}`; const collapsedRowsId = `query-pages-collapsed-${scorecardData.keyword.replace(/[^a-z0-9]/gi, '_')}`; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; // Always show best URL row (yellow highlighted) if (bestUrlPage) { pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; } // Collapsible section for other pages if (otherPages.length > 0) { pagesHtml += `
    Page URLClicksImpressionsCTRPosition
    `; pagesHtml += `${bestUrlPage.page} (DataForSEO best URL)`; pagesHtml += `${bestUrlPage.clicks.toLocaleString()}${bestUrlPage.impressions.toLocaleString()}${(bestUrlPage.ctr).toFixed(1)}%${Math.round(bestUrlPage.position)}
    `; pagesHtml += `
    `; pagesHtml += ``; pagesHtml += `
    `; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; otherPages.forEach(page => { pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; pagesHtml += ``; }); pagesHtml += ``; } else { pagesHtml += ``; } } queryPagesEl.innerHTML = pagesHtml; } else { queryPagesEl.innerHTML = `
    No pages found with impressions for this keyword.
    `; } } else { queryPagesEl.innerHTML = `
    Unable to load pages breakdown.
    `; } } catch (err) { console.error('Error fetching query→pages breakdown:', err); queryPagesEl.innerHTML = `
    Error loading pages breakdown: ${err.message}
    `; } })(); } // Update AI citations section in right panel const citationsEmpty = document.getElementById("ranking-ai-citations-empty"); const citationsContent = document.getElementById("ranking-ai-citations-content"); const ourList = document.getElementById("ranking-ai-detail-our-pages"); const compTbody = document.getElementById("ranking-ai-detail-competitors-body"); if (citationsEmpty && citationsContent) { citationsEmpty.hidden = true; citationsContent.hidden = false; if (ourList) { ourList.innerHTML = ""; if (!row.ai_alan_citations || !row.ai_alan_citations.length) { const li = document.createElement("li"); li.textContent = "No alanranger.com citations found for this AI Overview."; ourList.appendChild(li); } else { row.ai_alan_citations.forEach(c => { const li = document.createElement("li"); const a = document.createElement("a"); a.href = c.url; a.target = "_blank"; a.rel = "noopener noreferrer"; a.textContent = c.title || c.url; li.appendChild(a); ourList.appendChild(li); }); } } if (compTbody) { compTbody.innerHTML = ""; const entries = Object.entries(row.competitor_counts || {}).sort((a, b) => b[1] - a[1]); if (!entries.length) { const tr = document.createElement("tr"); const td = document.createElement("td"); td.colSpan = 5; td.style.padding = "1rem 0.75rem"; td.style.color = "#64748b"; td.style.fontSize = "0.875rem"; td.textContent = "No competing domains recorded from AI citations for this keyword."; tr.appendChild(td); compTbody.appendChild(tr); } else { const makeDomainHref = (domain) => { if (!domain) return null; const d = String(domain).trim(); if (!d) return null; if (d.startsWith('http://') || d.startsWith('https://')) return d; return `https://${d}`; }; // Fetch domain metadata for all domains const domainsList = entries.map(([d]) => d); (async () => { const domainMetadata = await fetchDomainMetadataForDomains(domainsList); entries.forEach(([domain, count]) => { const tr = document.createElement("tr"); tr.style.borderTop = "1px solid #e2e8f0"; const normalizedDomain = normalizeDomainForStrength(domain); const meta = domainMetadata[normalizedDomain] || { domain_type: 'unmapped', is_competitor: false }; const tdDomain = document.createElement("td"); tdDomain.style.padding = "0.5rem 0.4rem"; tdDomain.style.fontSize = "0.8rem"; tdDomain.style.wordWrap = "break-word"; tdDomain.style.overflowWrap = "break-word"; const domainContainer = document.createElement("div"); domainContainer.style.display = "flex"; domainContainer.style.flexDirection = "column"; domainContainer.style.alignItems = "flex-start"; domainContainer.style.gap = "0.25rem"; const domainLinkWrapper = document.createElement("div"); domainLinkWrapper.style.width = "100%"; const href = makeDomainHref(domain); if (href) { const a = document.createElement("a"); a.href = href; a.target = "_blank"; a.rel = "noopener noreferrer"; a.style.color = "#0284c7"; a.style.textDecoration = "none"; a.style.wordBreak = "break-word"; a.style.overflowWrap = "break-word"; a.textContent = domain; domainLinkWrapper.appendChild(a); } else { domainLinkWrapper.appendChild(document.createTextNode(String(domain || ''))); } domainContainer.appendChild(domainLinkWrapper); // Add competitor badge if is_competitor is true - below the domain if (meta.is_competitor) { const badge = document.createElement("span"); badge.textContent = "Competitor"; badge.setAttribute('data-competitor-badge', 'true'); badge.style.display = "inline-block"; badge.style.padding = "0.125rem 0.5rem"; badge.style.fontSize = "0.65rem"; badge.style.fontWeight = "600"; badge.style.color = "#dc2626"; badge.style.backgroundColor = "#fee2e2"; badge.style.borderRadius = "4px"; badge.style.border = "1px solid #fecaca"; badge.style.marginTop = "0.125rem"; domainContainer.appendChild(badge); } tdDomain.appendChild(domainContainer); const tdCount = document.createElement("td"); tdCount.style.padding = "0.5rem 0.4rem"; tdCount.style.textAlign = "center"; tdCount.style.fontWeight = "700"; tdCount.style.color = "#1e293b"; tdCount.style.fontSize = "0.8rem"; tdCount.textContent = `${count}`; const tdRank = document.createElement("td"); tdRank.style.padding = "0.5rem 0.4rem"; tdRank.style.textAlign = "right"; tdRank.style.color = "#64748b"; tdRank.style.fontSize = "0.8rem"; tdRank.textContent = "—"; tdRank.dataset.domain = normalizedDomain; const tdDomainType = document.createElement("td"); tdDomainType.style.padding = "0.5rem 0.4rem"; tdDomainType.style.fontSize = "0.8rem"; tdDomainType.style.wordWrap = "break-word"; tdDomainType.style.overflowWrap = "break-word"; // Show domain type, but hide "unmapped" (show blank instead) const displayType = meta.domain_type && meta.domain_type !== 'unmapped' ? meta.domain_type : ''; tdDomainType.textContent = displayType; tdDomainType.style.color = displayType ? '#475569' : '#94a3b8'; const tdCompetitor = document.createElement("td"); tdCompetitor.style.padding = "0.5rem 0.4rem"; tdCompetitor.style.textAlign = "center"; tdCompetitor.style.fontSize = "0.8rem"; tdCompetitor.style.minWidth = "80px"; const competitorCheckbox = document.createElement("input"); competitorCheckbox.type = "checkbox"; competitorCheckbox.checked = meta.is_competitor === true; competitorCheckbox.style.cursor = "pointer"; competitorCheckbox.dataset.domain = normalizedDomain; competitorCheckbox.addEventListener('change', async (e) => { const isCompetitor = e.target.checked; const domain = e.target.dataset.domain; try { const resp = await fetch(apiUrl('/api/domain-strength/update-domain'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain, is_competitor: isCompetitor }) }); if (resp.ok) { // Clear cache __domainMetadataCache.delete(domain); // Update badge - find domainContainer first const domainContainer = tdDomain.querySelector('div[style*="flex-direction: column"]') || tdDomain.querySelector('div'); let badge = tdDomain.querySelector('span[data-competitor-badge]'); if (isCompetitor && !badge && domainContainer) { badge = document.createElement("span"); badge.textContent = "Competitor"; badge.setAttribute('data-competitor-badge', 'true'); badge.style.display = "inline-block"; badge.style.padding = "0.125rem 0.5rem"; badge.style.fontSize = "0.65rem"; badge.style.fontWeight = "600"; badge.style.color = "#dc2626"; badge.style.backgroundColor = "#fee2e2"; badge.style.borderRadius = "4px"; badge.style.border = "1px solid #fecaca"; badge.style.marginTop = "0.125rem"; domainContainer.appendChild(badge); } else if (!isCompetitor && badge) { badge.remove(); } } else { // Revert on error e.target.checked = !isCompetitor; alert('Failed to update competitor flag. Please try again.'); } } catch (err) { e.target.checked = !isCompetitor; alert('Failed to update competitor flag. Please try again.'); } }); tdCompetitor.appendChild(competitorCheckbox); tr.appendChild(tdDomain); tr.appendChild(tdCount); tr.appendChild(tdRank); tr.appendChild(tdDomainType); tr.appendChild(tdCompetitor); compTbody.appendChild(tr); }); // Fill Domain Rank column AFTER all rows are appended to DOM try { const selfDomain = typeof getSelfDomainForDomainStrength === "function" ? getSelfDomainForDomainStrength() : "alanranger.com"; const domains = [ selfDomain, ...entries.map(([d]) => d), ].map(normalizeDomainForStrength).filter(Boolean); const strengthByDomain = await fetchLatestDomainStrengthForDomains(domains); const rankCells = compTbody.querySelectorAll("td[data-domain]"); if (rankCells.length === 0) { debugLog('⚠️ No Domain Rank cells found with data-domain attribute in AI Citations table', 'warn'); } else { debugLog(`✓ Filling Domain Rank for ${rankCells.length} domains in AI Citations table`, 'info'); } rankCells.forEach((cell) => { const d = normalizeDomainForStrength(cell.dataset.domain || ""); const strength = strengthByDomain[d] || null; cell.innerHTML = renderDomainRankCellHtml(strength); }); } catch (err) { debugLog(`✗ Error filling Domain Rank in AI Citations table: ${err.message}`, 'error'); } })(); } } } else if (citationsEmpty) { citationsEmpty.hidden = false; } } // Keep renderRankingAiDetail as alias for backward compatibility async function renderRankingAiDetail(row) { await renderKeywordScorecard(row); } // ====================== // Domain Rank (Domain Strength snapshots) for Keyword tables // ====================== const __domainStrengthLatestCache = new Map(); // domain -> { score, band, snapshotDate } const __domainMetadataCache = new Map(); // domain -> { domain_type, is_competitor } function normalizeDomainForStrength(input) { const raw = String(input || "").trim().toLowerCase(); if (!raw) return ""; try { if (raw.includes("://")) return new URL(raw).hostname.replace(/^www\./, ""); } catch { // ignore } return raw.replace(/^www\./, "").split("/")[0]; } function renderDomainStrengthBandPillOriginal(band) { const label = String(band || "").trim(); if (!label) return ""; const key = typeof domainStrengthBandKey === "function" ? domainStrengthBandKey(label) : "na"; return `${label}`; } function renderDomainRankCellHtml(strength) { if (!strength || typeof strength !== "object") { return ``; } const score = typeof strength.score === "number" && isFinite(strength.score) ? strength.score : null; const band = typeof strength.band === "string" ? strength.band : ""; if (score === null) { return ``; } const pill = renderDomainStrengthBandPillOriginal(band); const scoreRounded = Math.round(score); // Left-align numbers in fixed-width container so they line up vertically return `
    ${scoreRounded} ${pill || ""}
    `; } async function fetchLatestDomainStrengthForDomains(domains) { const list = Array.isArray(domains) ? domains.map(normalizeDomainForStrength).filter(Boolean) : []; const unique = Array.from(new Set(list)).slice(0, 30); if (!unique.length) return {}; // Serve from cache when available const out = {}; const missing = []; for (const d of unique) { if (__domainStrengthLatestCache.has(d)) { out[d] = __domainStrengthLatestCache.get(d); } else { missing.push(d); } } if (!missing.length) return out; try { const qs = encodeURIComponent(missing.join(",")); const resp = await fetch(apiUrl(`/api/domain-strength/history?domains=${qs}`)); if (!resp.ok) return out; const json = await resp.json(); const rows = json?.status === "ok" ? (json.data || []) : []; // Build latest per domain (google engine only) const latestByDomain = {}; for (const r of rows) { const d = normalizeDomainForStrength(r?.domain); if (!d) continue; if (String(r?.engine || "google").toLowerCase() !== "google") continue; const date = String(r?.snapshot_date || ""); const score = typeof r?.score === "number" ? r.score : parseFloat(r?.score); const band = typeof r?.band === "string" ? r.band : ""; if (!date) continue; const prev = latestByDomain[d]; if (!prev || String(prev.snapshotDate) < date) { latestByDomain[d] = { score: isFinite(score) ? score : null, band: band || null, snapshotDate: date }; } } for (const d of missing) { const v = latestByDomain[d] || { score: null, band: null, snapshotDate: null }; __domainStrengthLatestCache.set(d, v); out[d] = v; } } catch { // fail silently } return out; } async function fetchDomainMetadataForDomains(domains) { const list = Array.isArray(domains) ? domains.map(normalizeDomainForStrength).filter(Boolean) : []; const unique = Array.from(new Set(list)); if (!unique.length) return {}; // Serve from cache when available const out = {}; const missing = []; for (const d of unique) { if (__domainMetadataCache.has(d)) { out[d] = __domainMetadataCache.get(d); } else { missing.push(d); } } if (!missing.length) return out; try { // Fetch metadata from domain_strength_domains via overview API const qs = encodeURIComponent(missing.join(",")); const resp = await fetch(apiUrl(`/api/domain-strength/overview?domains=${qs}`)); if (!resp.ok) { // If API fails, return defaults for (const d of missing) { out[d] = { domain_type: 'unmapped', is_competitor: false }; __domainMetadataCache.set(d, out[d]); } return out; } const json = await resp.json(); const items = json?.status === "ok" ? (json.items || []) : []; // Build metadata map for (const item of items) { const d = normalizeDomainForStrength(item?.domain); if (!d) continue; const meta = { domain_type: item?.domain_type || item?.segment || 'unmapped', is_competitor: item?.isCompetitor === true || false }; __domainMetadataCache.set(d, meta); out[d] = meta; } // Fill in missing domains with defaults for (const d of missing) { if (!out[d]) { out[d] = { domain_type: 'unmapped', is_competitor: false }; __domainMetadataCache.set(d, out[d]); } } } catch (e) { // If fetch fails, return defaults for (const d of missing) { out[d] = { domain_type: 'unmapped', is_competitor: false }; __domainMetadataCache.set(d, out[d]); } } return out; } /** * Backfill missing Domain Ranks for domains currently showing "—" * Collects domains from "Other cited domains" table and competitor tables that have missing ranks */ async function backfillMissingDomainRanks() { const btn = document.getElementById('backfill-domain-ranks-btn'); if (!btn) return; // Disable button and show loading state const originalText = btn.textContent; btn.disabled = true; btn.textContent = 'Backfilling...'; btn.style.opacity = '0.6'; btn.style.cursor = 'not-allowed'; try { // Collect domains with missing ranks from "Other cited domains" table const compTbody = document.getElementById('ranking-ai-detail-competitors-body'); const missingDomains = new Set(); if (compTbody) { const rankCells = compTbody.querySelectorAll('td[data-domain]'); rankCells.forEach(cell => { // Check if cell shows "—" (missing rank) const cellText = cell.textContent.trim(); if (cellText === '—' || cellText === '') { const domain = cell.dataset.domain; if (domain) { const normalized = normalizeDomainForStrength(domain); if (normalized) { missingDomains.add(normalized); } } } }); } // Also check competitor tables in main ranking view const competitorTbody = document.getElementById('ranking-ai-competitors-body'); if (competitorTbody) { const competitorCells = competitorTbody.querySelectorAll('td[data-domain]'); competitorCells.forEach(cell => { const cellText = cell.textContent.trim(); if (cellText === '—' || cellText === '') { const domain = cell.dataset.domain; if (domain) { const normalized = normalizeDomainForStrength(domain); if (normalized) { missingDomains.add(normalized); } } } }); } const domainsToBackfill = Array.from(missingDomains); if (domainsToBackfill.length === 0) { alert('No domains with missing ranks found. All domains already have Domain Rank values.'); return; } debugLog(`🔄 Backfilling Domain Rank for ${domainsToBackfill.length} domains: ${domainsToBackfill.slice(0, 10).join(', ')}${domainsToBackfill.length > 10 ? '...' : ''}`, 'info'); // Call backfill API const adminToken = localStorage.getItem('admin_token') || ''; const response = await fetch(apiUrl('/api/domain-strength/backfill'), { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-admin-token': adminToken }, body: JSON.stringify({ mode: 'list', domains: domainsToBackfill, maxNewDomains: domainsToBackfill.length, dryRun: false, source: 'ui-backfill' }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Backfill failed: ${errorText}`); } const result = await response.json(); if (result.status === 'ok') { debugLog(`✅ Backfill complete: ${result.processed} processed, ${result.skipped_existing} skipped, ${result.errors?.length || 0} errors`, 'success'); // Clear domain strength cache for backfilled domains domainsToBackfill.forEach(d => { __domainStrengthLatestCache.delete(d); }); // Refresh domain rank cells if (compTbody) { const rankCells = compTbody.querySelectorAll('td[data-domain]'); const domains = Array.from(rankCells).map(cell => normalizeDomainForStrength(cell.dataset.domain || '')).filter(Boolean); if (domains.length > 0) { const strengthByDomain = await fetchLatestDomainStrengthForDomains(domains); rankCells.forEach(cell => { const d = normalizeDomainForStrength(cell.dataset.domain || ''); const strength = strengthByDomain[d] || null; cell.innerHTML = renderDomainRankCellHtml(strength); }); } } // Also refresh competitor table if visible if (competitorTbody) { const competitorCells = competitorTbody.querySelectorAll('td[data-domain]'); const competitorDomains = Array.from(competitorCells).map(cell => normalizeDomainForStrength(cell.dataset.domain || '')).filter(Boolean); if (competitorDomains.length > 0) { const strengthByDomain = await fetchLatestDomainStrengthForDomains(competitorDomains); competitorCells.forEach(cell => { const d = normalizeDomainForStrength(cell.dataset.domain || ''); const strength = strengthByDomain[d] || null; cell.innerHTML = renderDomainRankCellHtml(strength); }); } } alert(`Domain Rank backfill complete!\n\nProcessed: ${result.processed}\nSkipped (already exist): ${result.skipped_existing}\nErrors: ${result.errors?.length || 0}`); } else { throw new Error(result.message || 'Backfill failed'); } } catch (err) { debugLog(`✗ Domain Rank backfill error: ${err.message}`, 'error'); alert(`Failed to backfill Domain Ranks: ${err.message}`); } finally { // Restore button state btn.disabled = false; btn.textContent = originalText; btn.style.opacity = '1'; btn.style.cursor = 'pointer'; } } function renderRankingAiCompetitors(rows) { const tbody = document.getElementById("ranking-ai-competitors-body"); if (!tbody) return; tbody.innerHTML = ""; const aggregate = {}; rows.forEach(row => { Object.entries(row.competitor_counts || {}).forEach(([domain, count]) => { aggregate[domain] = (aggregate[domain] || 0) + count; }); }); const entries = Object.entries(aggregate).sort((a, b) => b[1] - a[1]).slice(0, 8); if (!entries.length) { const tr = document.createElement("tr"); const td = document.createElement("td"); td.colSpan = 5; td.style.padding = "1rem 0.75rem"; td.style.color = "#64748b"; td.style.fontSize = "0.875rem"; td.textContent = "No competitor citations recorded across tracked keywords."; tr.appendChild(td); tbody.appendChild(tr); return; } const makeDomainHref = (domain) => { if (!domain) return null; const d = String(domain).trim(); if (!d) return null; if (d.startsWith('http://') || d.startsWith('https://')) return d; return `https://${d}`; }; // Fetch domain metadata for all domains (async) const domainsList = entries.map(([d]) => d); (async () => { const domainMetadata = await fetchDomainMetadataForDomains(domainsList); entries.forEach(([domain, count]) => { const tr = document.createElement("tr"); tr.style.borderTop = "1px solid #e2e8f0"; const normalizedDomain = normalizeDomainForStrength(domain); const meta = domainMetadata[normalizedDomain] || { domain_type: 'unmapped', is_competitor: false }; const tdDomain = document.createElement("td"); tdDomain.style.padding = "0.5rem 0.4rem"; tdDomain.style.fontSize = "0.8rem"; tdDomain.style.wordWrap = "break-word"; tdDomain.style.overflowWrap = "break-word"; const domainContainer = document.createElement("div"); domainContainer.style.display = "flex"; domainContainer.style.flexDirection = "column"; domainContainer.style.alignItems = "flex-start"; domainContainer.style.gap = "0.25rem"; const domainLinkWrapper = document.createElement("div"); domainLinkWrapper.style.width = "100%"; const href = makeDomainHref(domain); if (href) { const a = document.createElement("a"); a.href = href; a.target = "_blank"; a.rel = "noopener noreferrer"; a.style.color = "#0284c7"; a.style.textDecoration = "none"; a.style.wordBreak = "break-word"; a.style.overflowWrap = "break-word"; a.textContent = domain; domainLinkWrapper.appendChild(a); } else { domainLinkWrapper.appendChild(document.createTextNode(String(domain || ''))); } domainContainer.appendChild(domainLinkWrapper); // Add competitor badge if is_competitor is true - below the domain if (meta.is_competitor) { const badge = document.createElement("span"); badge.textContent = "Competitor"; badge.setAttribute('data-competitor-badge', 'true'); badge.style.display = "inline-block"; badge.style.padding = "0.125rem 0.5rem"; badge.style.fontSize = "0.65rem"; badge.style.fontWeight = "600"; badge.style.color = "#dc2626"; badge.style.backgroundColor = "#fee2e2"; badge.style.borderRadius = "4px"; badge.style.border = "1px solid #fecaca"; badge.style.marginTop = "0.125rem"; domainContainer.appendChild(badge); } tdDomain.appendChild(domainContainer); const tdCount = document.createElement("td"); tdCount.style.padding = "0.5rem 0.4rem"; tdCount.style.textAlign = "center"; tdCount.style.fontWeight = "700"; tdCount.style.color = "#1e293b"; tdCount.style.fontSize = "0.8rem"; tdCount.textContent = `${count}`; const tdRank = document.createElement("td"); tdRank.style.padding = "0.5rem 0.4rem"; tdRank.style.textAlign = "right"; tdRank.style.color = "#64748b"; tdRank.style.fontSize = "0.8rem"; tdRank.textContent = "—"; tdRank.dataset.domain = normalizedDomain; const tdDomainType = document.createElement("td"); tdDomainType.style.padding = "0.5rem 0.4rem"; tdDomainType.style.fontSize = "0.8rem"; tdDomainType.style.wordWrap = "break-word"; tdDomainType.style.overflowWrap = "break-word"; // Show domain type, but hide "unmapped" (show blank instead) const displayType = meta.domain_type && meta.domain_type !== 'unmapped' ? meta.domain_type : ''; tdDomainType.textContent = displayType; tdDomainType.style.color = displayType ? '#475569' : '#94a3b8'; const tdCompetitor = document.createElement("td"); tdCompetitor.style.padding = "0.5rem 0.4rem"; tdCompetitor.style.textAlign = "center"; tdCompetitor.style.fontSize = "0.8rem"; tdCompetitor.style.minWidth = "40px"; const competitorCheckbox = document.createElement("input"); competitorCheckbox.type = "checkbox"; competitorCheckbox.checked = meta.is_competitor === true; competitorCheckbox.style.cursor = "pointer"; competitorCheckbox.dataset.domain = normalizedDomain; competitorCheckbox.addEventListener('change', async (e) => { const isCompetitor = e.target.checked; const domain = e.target.dataset.domain; try { const resp = await fetch(apiUrl('/api/domain-strength/update-domain'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain, is_competitor: isCompetitor }) }); if (resp.ok) { // Clear cache __domainMetadataCache.delete(domain); // Update badge - find domainContainer first const domainContainer = tdDomain.querySelector('div[style*="flex-direction: column"]') || tdDomain.querySelector('div'); let badge = tdDomain.querySelector('span[data-competitor-badge]'); if (isCompetitor && !badge && domainContainer) { badge = document.createElement("span"); badge.textContent = "Competitor"; badge.setAttribute('data-competitor-badge', 'true'); badge.style.display = "inline-block"; badge.style.padding = "0.125rem 0.5rem"; badge.style.fontSize = "0.65rem"; badge.style.fontWeight = "600"; badge.style.color = "#dc2626"; badge.style.backgroundColor = "#fee2e2"; badge.style.borderRadius = "4px"; badge.style.border = "1px solid #fecaca"; badge.style.marginTop = "0.125rem"; domainContainer.appendChild(badge); } else if (!isCompetitor && badge) { badge.remove(); } } else { // Revert on error e.target.checked = !isCompetitor; alert('Failed to update competitor flag. Please try again.'); } } catch (err) { e.target.checked = !isCompetitor; alert('Failed to update competitor flag. Please try again.'); } }); tdCompetitor.appendChild(competitorCheckbox); tr.appendChild(tdDomain); tr.appendChild(tdCount); tr.appendChild(tdRank); tr.appendChild(tdDomainType); tr.appendChild(tdCompetitor); tbody.appendChild(tr); }); // Fill Domain Rank column using latest domain_strength snapshots (read-only) try { const selfDomain = typeof getSelfDomainForDomainStrength === "function" ? getSelfDomainForDomainStrength() : "alanranger.com"; const domains = [ selfDomain, ...entries.map(([d]) => d), ].map(normalizeDomainForStrength).filter(Boolean); const strengthByDomain = await fetchLatestDomainStrengthForDomains(domains); tbody.querySelectorAll("td[data-domain]").forEach((cell) => { const d = normalizeDomainForStrength(cell.dataset.domain || ""); const strength = strengthByDomain[d] || null; cell.innerHTML = renderDomainRankCellHtml(strength); }); } catch { // fail silently } })(); } function renderRankingAiInsights(rows, summary) { const container = document.getElementById("ranking-ai-insights-list-global"); if (!container) return; container.innerHTML = ""; if (!rows.length) return; // Calculate goodRankWithOverviewNoCitation: keywords with good rank (top-10), AI Overview, but no citations const goodRankWithOverviewNoCitation = rows.filter( r => r.best_rank_group != null && r.best_rank_group <= 10 && r.has_ai_overview && r.ai_alan_citations_count === 0 ).length; // Calculate weakRankWithCitation: keywords with weaker rank (21+), but alanranger.com is cited const weakRankWithCitation = rows.filter( r => (r.best_rank_group == null || r.best_rank_group > 20) && r.ai_alan_citations_count > 0 ).length; // Create card pills for the two main insights if (goodRankWithOverviewNoCitation > 0) { const pill = document.createElement("div"); pill.className = "card-pill"; pill.innerHTML = `

    [Visibility, Content/Schema] ${goodRankWithOverviewNoCitation} keyword(s) have strong classic rankings and an AI Overview, but no citations for alanranger.com. Improving snippet-friendly content blocks and structured data on those pages can help convert existing Visibility into AI citations.

    `; container.appendChild(pill); } if (weakRankWithCitation > 0) { const pill = document.createElement("div"); pill.className = "card-pill"; pill.innerHTML = `

    [Authority, Visibility] ${weakRankWithCitation} keyword(s) already cite your content in AI Overviews despite weaker classic rankings. This indicates strong topical Authority; improving backlinks and on-page optimisation could lift classic Visibility for these terms.

    `; container.appendChild(pill); } // Authority priority insight (v1.4: Domain Strength integration) const authorityPriority = summary?.authorityPriority ?? null; const domainStrength = summary?.domainStrength ?? null; if (authorityPriority !== null) { const pill = document.createElement("div"); pill.className = "card-pill"; let authorityText = ''; if (authorityPriority === 'high') { authorityText = 'Domain authority: Low. Overall domain strength is limiting how far your pages can climb, especially for high-impact keywords. Treat "authority building" (links, citations, brand searches) as a high-priority task over the next few months.'; } else if (authorityPriority === 'medium') { authorityText = 'Domain authority: Medium. Authority is "good enough" but still a constraint on some high-impact keywords. Mix authority-building with on-page improvements.'; } else { // authorityPriority === 'low' authorityText = 'Domain authority: Strong relative to your current scale. Most gains are likely to come from on-page content, snippets, and conversion rather than more links alone.'; } pill.innerHTML = `

    [Authority] ${authorityText}

    `; container.appendChild(pill); } // If no insights, show a general message if (goodRankWithOverviewNoCitation === 0 && weakRankWithCitation === 0 && authorityPriority === null) { const pill = document.createElement("div"); pill.className = "card-pill"; pill.innerHTML = `

    Current ranking and AI signals are broadly aligned. Focus on incremental improvements to Money-page CTR, schema coverage, and consolidating reviews to keep Authority and Content/Schema strong.

    `; container.appendChild(pill); } } // Tab switching for sidebar navigation - Optimized for performance (INP) (function() { // Restore tab state on page load (from sessionStorage or URL hash) function restoreTabState() { // Check URL hash first (more reliable) const hash = window.location.hash; if (hash && hash.startsWith('#')) { const panelId = hash.substring(1); const panel = document.querySelector(`[data-panel="${panelId}"]`); if (panel) { setActivePanel(panelId); ensurePanelRendered(panelId); return; } } // Fallback to sessionStorage const savedTab = sessionStorage.getItem('activeTab'); if (savedTab) { const panel = document.querySelector(`[data-panel="${savedTab}"]`); if (panel) { setActivePanel(savedTab); ensurePanelRendered(savedTab); // Update URL hash to match window.location.hash = '#' + savedTab; return; } } } // Restore tab state when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', restoreTabState); } else { // DOM already loaded, restore immediately restoreTabState(); } const navItems = document.querySelectorAll(".aigeo-nav-item"); navItems.forEach(btn => { btn.addEventListener("click", () => { const panelId = btn.getAttribute("data-panel"); if (!panelId) return; // Store active tab in sessionStorage sessionStorage.setItem('activeTab', panelId); window.location.hash = '#' + panelId; // Fast UI switch first - no heavy work here setActivePanel(panelId); // Defer heavy work so the click paints immediately requestAnimationFrame(() => { defer(() => { // Try to refresh audit data from Supabase when switching tabs (if data seems stale or missing) const propertyUrl = localStorage.getItem('gsc_property_url'); const savedAudit = loadAuditResultsSync(); const savedTimestamp = savedAudit?.timestamp; const hoursSinceLastAudit = savedTimestamp ? (Date.now() - savedTimestamp) / (1000 * 60 * 60) : Infinity; // If no data or data is more than 1 hour old, try to refresh from Supabase if ((!savedAudit || hoursSinceLastAudit > 1) && propertyUrl) { (async () => { debugLog(`🔄 Tab switch: Attempting to refresh audit data from Supabase (${hoursSinceLastAudit > 1 ? 'data is stale' : 'no data found'})...`, 'info'); try { const freshData = await fetchLatestAuditFromSupabase(propertyUrl); if (freshData && freshData.timestamp) { const freshTimestamp = freshData.timestamp; // Only update if fresh data is newer if (!savedTimestamp || freshTimestamp > savedTimestamp) { safeSetLocalStorage('last_audit_results', freshData); updateAuditTimestamp(freshTimestamp); debugLog(`✓ Tab switch: Refreshed audit data from Supabase (newer timestamp)`, 'success'); // Dashboard: refresh live dials/cards if we just pulled newer audit data. if (typeof window.renderDashboardTab === 'function') { try { window.renderDashboardTab(); } catch {} } } else { debugLog(`⚠ Tab switch: Supabase data is not newer than cached data`, 'info'); } } } catch (error) { debugLog(`⚠ Tab switch: Failed to refresh from Supabase: ${error.message}`, 'warn'); } })(); } // Lazy-render panel content (only first time) ensurePanelRendered(panelId); }); }); }); }); })(); // Function to refresh only GSC queryTotals (CTR/Impressions) without running full audit window.refreshGSCDataOnly = async function refreshGSCDataOnly() { try { debugLog('🔄 Refreshing GSC data only (CTR & Impressions)...', 'info'); // Get existing audit data const savedAudit = loadAuditResultsSync(); if (!savedAudit || !savedAudit.searchData) { debugLog('⚠ No audit data found. Please run a full audit first.', 'warn'); alert('No audit data found. Please run a full audit first.'); return; } // Get keywords from existing ranking data let rankingData = []; // Try to get from RankingAiModule state first if (window.RankingAiModule && typeof window.RankingAiModule.state === 'function') { const state = window.RankingAiModule.state(); rankingData = state.combinedRows || []; debugLog(`📊 Found ${rankingData.length} keywords from RankingAiModule state`, 'info'); } // Fallback: try to get from localStorage (check both possible keys) if (!rankingData || rankingData.length === 0) { try { let storedData = localStorage.getItem('rankingAiData'); // Primary key if (!storedData) { storedData = localStorage.getItem('ranking_ai_data'); // Alternative key } if (storedData) { const parsed = JSON.parse(storedData); if (parsed && parsed.combinedRows && Array.isArray(parsed.combinedRows)) { rankingData = parsed.combinedRows; debugLog(`📊 Found ${rankingData.length} keywords from localStorage`, 'info'); } } } catch (e) { debugLog(`⚠ Failed to parse ranking data from localStorage: ${e.message}`, 'warn'); } } // Fallback: try to get from saved audit's keyword_rankings if (!rankingData || rankingData.length === 0) { if (savedAudit.keywordRankings && Array.isArray(savedAudit.keywordRankings)) { rankingData = savedAudit.keywordRankings; debugLog(`📊 Found ${rankingData.length} keywords from saved audit`, 'info'); } } // Final fallback: fetch keywords directly from Supabase if (!rankingData || rankingData.length === 0) { debugLog('⚠ No ranking keywords in UI state, fetching from Supabase...', 'info'); try { const propertyUrl = localStorage.getItem('gsc_property_url') || localStorage.getItem('last_property_url') || ''; if (propertyUrl) { const supabaseResponse = await fetch(apiUrl(`/api/supabase/get-latest-audit?propertyUrl=${encodeURIComponent(propertyUrl)}`)); if (supabaseResponse.ok) { const supabaseData = await supabaseResponse.json(); if (supabaseData.status === 'ok' && supabaseData.data?.rankingAiData?.combinedRows) { rankingData = supabaseData.data.rankingAiData.combinedRows; debugLog(`✓ Fetched ${rankingData.length} keywords directly from Supabase`, 'success'); } } } } catch (supabaseErr) { debugLog(`⚠ Failed to fetch keywords from Supabase: ${supabaseErr.message}`, 'warn'); } } if (!rankingData || rankingData.length === 0) { debugLog('⚠ No ranking keywords found. Please run the ranking scan first.', 'warn'); alert('No ranking keywords found. Please run the ranking scan first.'); return; } const allKeywords = rankingData.map(r => r.keyword).filter(k => k && k.trim()); if (allKeywords.length === 0) { debugLog('⚠ No valid keywords found.', 'warn'); alert('No valid keywords found.'); return; } debugLog(`📊 Fetching GSC queryTotals for ${allKeywords.length} keywords...`, 'info'); const propertyUrl = localStorage.getItem('gsc_property_url') || localStorage.getItem('last_property_url') || ''; if (!propertyUrl) { debugLog('⚠ No property URL found.', 'warn'); alert('No property URL found. Please configure GSC settings first.'); return; } const dateRange = parseInt(localStorage.getItem('gsc_date_range') || '28'); // Fetch queryTotals from GSC API const keywordsParam = encodeURIComponent(JSON.stringify(allKeywords)); const propertyParam = encodeURIComponent(propertyUrl); const gscResponse = await fetch(apiUrl(`/api/aigeo/gsc-entity-metrics?property=${propertyParam}&keywords=${keywordsParam}&days=${dateRange}`)); if (!gscResponse.ok) { const errorText = await gscResponse.text(); debugLog(`✗ Failed to fetch queryTotals from GSC: ${gscResponse.status} - ${errorText}`, 'error'); alert(`Failed to fetch GSC data: ${gscResponse.status}`); return; } const gscData = await gscResponse.json(); if (gscData.status !== 'ok' || !gscData.data || !Array.isArray(gscData.data.queryTotals)) { debugLog(`⚠ GSC API did not return queryTotals data`, 'warn'); alert('GSC API did not return queryTotals data.'); return; } const queryTotals = gscData.data.queryTotals; debugLog(`✓ Fetched queryTotals for ${queryTotals.length} keywords from GSC`, 'success'); // Merge queryTotals into searchData savedAudit.searchData.queryTotals = queryTotals; // Use the SAME audit_date as the existing audit let auditDate = new Date().toISOString().split('T')[0]; if (savedAudit.timestamp) { try { auditDate = new Date(savedAudit.timestamp).toISOString().split('T')[0]; debugLog(`📊 Using existing audit date for queryTotals: ${auditDate}`, 'info'); } catch (e) { debugLog(`⚠ Failed to parse saved audit timestamp, using today's date`, 'warn'); } } // Save to Supabase const saveResponse = await fetch(apiUrl('/api/supabase/save-audit'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ propertyUrl: propertyUrl, auditDate: auditDate, searchData: savedAudit.searchData }) }); if (!saveResponse.ok) { const errorText = await saveResponse.text(); debugLog(`✗ Failed to save queryTotals to Supabase: ${saveResponse.status} - ${errorText}`, 'error'); alert(`Failed to save to Supabase: ${saveResponse.status}`); return; } debugLog(`✓ Saved queryTotals to Supabase (${queryTotals.length} keywords) for audit_date: ${auditDate}`, 'success'); // Update localStorage try { safeSetLocalStorage('last_audit_results', savedAudit); debugLog(`✓ Updated localStorage with queryTotals`, 'success'); } catch (localStorageErr) { debugLog(`⚠ Failed to update localStorage: ${localStorageErr.message}`, 'warn'); } // CRITICAL: Reload ranking data from Supabase to get latest search_volume values // The UI might be showing old localStorage data that doesn't have search_volume debugLog('🔄 Reloading ranking data from Supabase to get latest search_volume...', 'info'); try { const refreshedRankingData = await loadRankingAiDataFromStorage(true); // Force Supabase check if (refreshedRankingData && refreshedRankingData.combinedRows) { const mod = window.RankingAiModule; if (mod && typeof mod.setData === 'function') { const normalizedSummary = normalizeSummaryFields(refreshedRankingData.summary); mod.setData(refreshedRankingData.combinedRows, normalizedSummary); debugLog(`✓ Reloaded ${refreshedRankingData.combinedRows.length} keywords from Supabase with latest search_volume`, 'success'); // Check how many keywords now have search_volume const keywordsWithVolume = refreshedRankingData.combinedRows.filter(r => r.search_volume != null && r.search_volume !== undefined).length; debugLog(` Keywords with search_volume: ${keywordsWithVolume}/${refreshedRankingData.combinedRows.length}`, 'info'); } } } catch (reloadErr) { debugLog(`⚠ Failed to reload ranking data from Supabase: ${reloadErr.message}`, 'warn'); // Continue anyway - at least GSC data was refreshed } // Re-render the table to show updated CTR/Impressions and search_volume if (typeof renderRankingAiTab === 'function') { renderRankingAiTab(); debugLog('✓ Table re-rendered with updated GSC data and search_volume', 'success'); } // Update last run timestamp updateAuditTimestamp(new Date().toISOString()); debugLog('✓ GSC data refresh completed successfully!', 'success'); alert(`Successfully refreshed GSC data for ${queryTotals.length} keywords!`); } catch (error) { debugLog(`✗ Error refreshing GSC data: ${error.message}`, 'error'); console.error('GSC refresh error:', error); alert(`Error refreshing GSC data: ${error.message}`); } } // Manual refresh button for Ranking & AI function wireRankingAiButton() { const refreshBtn = document.getElementById("ranking-ai-refresh"); if (refreshBtn) { // Remove existing listener by cloning const newBtn = refreshBtn.cloneNode(true); refreshBtn.parentNode.replaceChild(newBtn, refreshBtn); newBtn.addEventListener("click", async () => { debugLog('🔄 Ranking & AI button clicked', 'info'); // Update UI immediately to show progress const lastRunEl = document.getElementById("ranking-ai-last-run"); if (lastRunEl) { lastRunEl.textContent = "Starting..."; } newBtn.disabled = true; newBtn.textContent = "Loading…"; try { if (typeof loadRankingAiData === 'function') { debugLog('✓ Calling loadRankingAiData (local)', 'info'); await loadRankingAiData(true); // force re-run } else if (typeof window.loadRankingAiData === 'function') { debugLog('✓ Calling loadRankingAiData (window)', 'info'); await window.loadRankingAiData(true); // force re-run } else { debugLog('✗ loadRankingAiData function not found', 'error'); if (lastRunEl) { lastRunEl.textContent = "Error: loadRankingAiData function not found"; } newBtn.disabled = false; newBtn.textContent = "Run ranking & AI check"; } } catch (err) { debugLog(`✗ Error calling loadRankingAiData: ${err.message}`, 'error'); console.error('Ranking & AI button error:', err); if (lastRunEl) { lastRunEl.textContent = `Error: ${err.message}`; } newBtn.disabled = false; newBtn.textContent = "Run ranking & AI check"; } }); debugLog('✓ Ranking & AI button wired up', 'success'); } else { debugLog('⚠ Ranking & AI button not found', 'warn'); } // Wire up GSC refresh button const gscRefreshBtn = document.getElementById("ranking-gsc-refresh"); if (gscRefreshBtn) { // Remove existing listener by cloning const newGscBtn = gscRefreshBtn.cloneNode(true); gscRefreshBtn.parentNode.replaceChild(newGscBtn, gscRefreshBtn); newGscBtn.addEventListener("click", async () => { debugLog('🔄 GSC refresh button clicked', 'info'); // Update UI immediately to show progress const lastRunEl = document.getElementById("ranking-ai-last-run"); if (lastRunEl) { lastRunEl.textContent = "Refreshing GSC data..."; } newGscBtn.disabled = true; newGscBtn.textContent = "Refreshing..."; try { await window.refreshGSCDataOnly(); } catch (err) { debugLog(`✗ Error refreshing GSC data: ${err.message}`, 'error'); console.error('GSC refresh button error:', err); if (lastRunEl) { lastRunEl.textContent = `Error: ${err.message}`; } } finally { newGscBtn.disabled = false; newGscBtn.textContent = "Refresh GSC Data"; if (lastRunEl) { const now = new Date(); const day = String(now.getUTCDate()).padStart(2, '0'); const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const month = monthNames[now.getUTCMonth()]; const year = now.getUTCFullYear(); const hours = String(now.getUTCHours()).padStart(2, '0'); const minutes = String(now.getUTCMinutes()).padStart(2, '0'); const seconds = String(now.getUTCSeconds()).padStart(2, '0'); lastRunEl.textContent = `Last refreshed: ${day} ${month} ${year}, ${hours}:${minutes}:${seconds} GMT`; } } }); debugLog('✓ GSC refresh button wired up', 'success'); } else { debugLog('⚠ GSC refresh button not found', 'warn'); } } // ====================== // Domain Strength (manual monthly snapshot) // ====================== function getSelfDomainForDomainStrength() { const propertyUrl = document.getElementById('propertyUrl')?.value || localStorage.getItem('gsc_property_url') || 'https://www.alanranger.com'; try { return new URL(propertyUrl).hostname.replace(/^www\./, ''); } catch { return String(propertyUrl).replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0]; } } function domainStrengthBandKey(band) { const b = String(band || '').trim(); if (b === 'Very strong') return 'very-strong'; if (b === 'Strong') return 'strong'; if (b === 'Moderate') return 'moderate'; if (b === 'Weak') return 'weak'; if (b === 'Very weak') return 'very-weak'; return 'na'; } function renderDomainStrengthBandPill(band) { const original = String(band || '—'); const key = domainStrengthBandKey(original); // v1.3 UI label mapping: Low / Medium / High / Very High (derived from stored band) let label = '—'; if (original === 'Very strong') label = 'Very High'; else if (original === 'Strong') label = 'High'; else if (original === 'Moderate') label = 'Medium'; else if (original === 'Weak' || original === 'Very weak') label = 'Low'; else label = original; return `${label}`; } function formatDomainStrengthScore(score) { const n = typeof score === 'number' ? score : parseFloat(score); if (!isFinite(n)) return '—'; return Number(n).toFixed(1); } function computeDomainStrengthDelta(sortedAscRows) { const list = Array.isArray(sortedAscRows) ? sortedAscRows : []; if (list.length < 2) return null; const last = list[list.length - 1]; const prev = list[list.length - 2]; const a = typeof last?.score === 'number' ? last.score : parseFloat(last?.score); const b = typeof prev?.score === 'number' ? prev.score : parseFloat(prev?.score); if (!isFinite(a) || !isFinite(b)) return null; return a - b; } function getDomainStrengthDomainList(maxCompetitors = 20) { const domains = []; const seen = new Set(); const self = getSelfDomainForDomainStrength(); if (self) { seen.add(self); domains.push(self); } // Use Ranking AI combined rows as the source of competitor domains const mod = window.RankingAiModule; const combinedRows = mod?.state?.().combinedRows || []; const counts = {}; for (const row of combinedRows) { const cc = row?.competitor_counts; if (!cc || typeof cc !== 'object') continue; for (const [domain, count] of Object.entries(cc)) { const d = String(domain || '').trim().replace(/^www\./, ''); if (!d || seen.has(d)) continue; counts[d] = (counts[d] || 0) + (Number(count) || 0); } } const sorted = Object.entries(counts) .sort((a, b) => b[1] - a[1]) .slice(0, Math.max(0, maxCompetitors)) .map(([d]) => d); for (const d of sorted) { if (seen.has(d)) continue; seen.add(d); domains.push(d); } return domains; } const domainStrengthSparklineCharts = new Map(); function safeDestroyDomainStrengthSparklines() { for (const ch of domainStrengthSparklineCharts.values()) { try { ch.destroy(); } catch { /* ignore */ } } domainStrengthSparklineCharts.clear(); } function renderDomainStrengthSparklineChart(canvas, labels, scores) { if (!canvas) return; if (!window.Chart) { canvas.replaceWith(document.createTextNode('—')); return; } const id = canvas.id || ''; const existing = id ? domainStrengthSparklineCharts.get(id) : null; if (existing) { try { existing.destroy(); } catch { /* ignore */ } domainStrengthSparklineCharts.delete(id); } const pts = Array.isArray(scores) ? scores.map((v) => (typeof v === 'number' ? v : parseFloat(v))).filter((v) => isFinite(v)) : []; const labs = Array.isArray(labels) ? labels : []; if (pts.length === 0) { canvas.replaceWith(document.createTextNode('—')); return; } const ctx = canvas.getContext('2d'); const chart = new Chart(ctx, { type: 'line', data: { labels: labs, datasets: [{ data: pts, borderColor: '#0284c7', backgroundColor: 'rgba(2, 132, 199, 0.15)', borderWidth: 2, pointRadius: pts.length === 1 ? 2.5 : 0, pointHoverRadius: 0, fill: false, tension: 0.35, }] }, options: { responsive: false, maintainAspectRatio: false, animation: false, plugins: { legend: { display: false }, tooltip: { enabled: false }, }, scales: { x: { display: false }, y: { display: false, min: 0, max: 100 } }, elements: { point: { hitRadius: 0 } } } }); if (id) domainStrengthSparklineCharts.set(id, chart); } async function fetchDomainStrengthOverview() { const resp = await fetch(apiUrl('/api/domain-strength/overview')); const json = await resp.json(); return json?.status === 'ok' ? (json.items || []) : []; } function formatIntegerOrDash(v) { const n = typeof v === 'number' ? v : parseFloat(v); if (!isFinite(n)) return '—'; return Math.round(n).toLocaleString(); } function formatEtvDollars(v) { const n = typeof v === 'number' ? v : parseFloat(v); if (!isFinite(n)) return '—'; return '$' + Math.round(n).toLocaleString(); } function formatDelta(delta) { if (delta === null || !isFinite(delta)) return '—'; const sign = delta > 0 ? '+' : ''; return `${sign}${delta.toFixed(1)}`; } // Domain Strength sorting and pagination state let domainStrengthSortState = { column: 'name', // Default sort by name (alphabetical) direction: 'asc' }; let domainStrengthPaginationState = { currentPage: 1, rowsPerPage: 10 }; let domainStrengthFilterState = { segment: null // Show all domains by default }; let domainStrengthExpanded = false; // Collapsed by default // Sort domain strength rows function sortDomainStrengthRows(rows) { const sorted = [...rows]; sorted.sort((a, b) => { let aVal, bVal; switch (domainStrengthSortState.column) { case 'name': aVal = (a?.label || a?.domain || '').toLowerCase(); bVal = (b?.label || b?.domain || '').toLowerCase(); break; case 'segment': aVal = a?.segment || ''; bVal = b?.segment || ''; break; case 'score': const as = a?.latest?.score; const bs = b?.latest?.score; aVal = typeof as === 'number' ? as : (isFinite(parseFloat(as)) ? parseFloat(as) : -1); bVal = typeof bs === 'number' ? bs : (isFinite(parseFloat(bs)) ? parseFloat(bs) : -1); break; case 'band': aVal = a?.latest?.band || ''; bVal = b?.latest?.band || ''; break; case 'etv': aVal = a?.latest?.organicEtv ?? 0; bVal = b?.latest?.organicEtv ?? 0; break; case 'top10': aVal = a?.latest?.top10Keywords ?? 0; bVal = b?.latest?.top10Keywords ?? 0; break; case 'change': aVal = a?.trend?.deltaLatest ?? -999; bVal = b?.trend?.deltaLatest ?? -999; break; default: return 0; } if (aVal < bVal) return domainStrengthSortState.direction === 'asc' ? -1 : 1; if (aVal > bVal) return domainStrengthSortState.direction === 'asc' ? 1 : -1; return 0; }); return sorted; } async function renderDomainStrengthSection() { debugLog('[DomainStrength] renderDomainStrengthSection() called', 'info'); const card = document.getElementById('domain-strength-summary-card'); const tbody = document.getElementById('domain-strength-table-body'); if (!tbody || !card) { debugLog('[DomainStrength] Missing card or tbody element', 'warn'); return; } card.innerHTML = `
    Loading domain strength…
    `; tbody.innerHTML = 'Loading…'; let items = []; try { debugLog('[DomainStrength] Fetching domain strength overview...', 'info'); items = await fetchDomainStrengthOverview(); debugLog(`[DomainStrength] Fetched ${items.length} items from API`, 'info'); } catch (e) { const msg = e?.message || String(e); card.innerHTML = `
    Failed to load: ${msg}
    `; tbody.innerHTML = `Failed to load: ${msg}`; return; } // v1.3: keep current single-engine view unless you add Bing later const engine = 'google'; items = items.filter((it) => String(it?.searchEngine || '').toLowerCase() === engine); if (!items.length) { card.innerHTML = `
    Domain Strength
    No domain strength snapshots yet. Run a snapshot to populate this panel.
    `; tbody.innerHTML = 'No domain strength snapshots yet. Click "Run Domain Strength Snapshot".'; return; } safeDestroyDomainStrengthSparklines(); const selfDomain = getSelfDomainForDomainStrength(); debugLog(`[DomainStrength] Self domain: ${selfDomain}`, 'info'); // Don't filter - show all domains // Separate your site from competitors const selfItem = items.find((it) => String(it?.domain || '') === selfDomain) || null; // Only show domains EXPLICITLY marked as competitors (checkbox checked) // isCompetitor must be explicitly true, not just truthy const competitorItems = items.filter((it) => { const isCompetitor = it?.isCompetitor === true; // Only true, not just truthy const isNotSelf = String(it?.domain || '') !== selfDomain; return isCompetitor && isNotSelf; }); console.log('[DomainStrength] Total items:', items.length, 'Competitors found:', competitorItems.length); if (selfItem && selfItem.latest) { debugLog(`[DomainStrength] Self item found: domain=${selfItem.domain}, score=${selfItem.latest.score}, snapshotDate=${selfItem.latest.snapshotDate}, createdAt=${selfItem.latest.createdAt}`, 'info'); } else { debugLog(`[DomainStrength] Self item NOT found for domain: ${selfDomain}`, 'warn'); } // Quick insight summary (your primary domain) if (selfItem && selfItem.latest) { const latest = selfItem.latest; debugLog(`[DomainStrength] Latest data: score=${latest.score}, snapshotDate=${latest.snapshotDate}, createdAt=${latest.createdAt}`, 'info'); const delta = selfItem?.trend?.deltaLatest ?? null; const arrow = delta > 0.5 ? '↑' : (delta < -0.5 ? '↓' : '•'); const cls = delta > 0.5 ? 'kpi-trend-up' : (delta < -0.5 ? 'kpi-trend-down' : 'kpi-trend-flat'); const changeText = delta === null ? '—' : formatDelta(delta); const bandPill = renderDomainStrengthBandPill(latest.band); const label = selfItem.label || selfDomain; // Format last fetched timestamp (prefer createdAt, fallback to snapshotDate) in GMT let lastFetchedText = '—'; const timestampToUse = latest.createdAt || latest.snapshotDate; if (timestampToUse) { try { const timestamp = new Date(timestampToUse); if (!isNaN(timestamp.getTime())) { // Convert to GMT/UTC lastFetchedText = timestamp.toLocaleString('en-GB', { timeZone: 'UTC', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }) + ' GMT'; } } catch (e) { // If date parsing fails, use raw string lastFetchedText = String(timestampToUse); } } card.innerHTML = `
    ${label}
    Last Fetched
    ${lastFetchedText}
    ${formatDomainStrengthScore(latest.score)}
    ${bandPill}
    ${arrow} ${delta === null ? 'No previous snapshot data yet' : `${changeText} vs last snapshot`}
    Domain strength ${formatDomainStrengthScore(latest.score)} on Google – ${delta === null ? 'no previous snapshot yet' : `${changeText} vs last snapshot`}; ${formatIntegerOrDash(latest.top10Keywords)} top‑10 keywords and estimated traffic ${formatEtvDollars(latest.organicEtv)}/month.
    `; } else { card.innerHTML = `
    ${selfDomain || 'Your site'}
    No domain strength snapshots yet. Run a snapshot to populate this card.
    `; } // Sort competitors by strength score (highest to lowest) - ALWAYS by score desc // This ensures competitors are shown in order of strength (highest first) const sortedCompetitors = [...competitorItems].sort((a, b) => { const aScore = typeof a?.latest?.score === 'number' ? a.latest.score : (parseFloat(a?.latest?.score) || 0); const bScore = typeof b?.latest?.score === 'number' ? b.latest.score : (parseFloat(b?.latest?.score) || 0); return bScore - aScore; // Descending - highest score first }); console.log('[DomainStrength] Competitors found:', sortedCompetitors.length, 'sorted by strength score (highest first)'); // Build final sorted array: your site first, then top competitors let sorted = []; if (selfItem) { sorted.push(selfItem); } sorted.push(...sortedCompetitors); // Calculate pagination const totalRows = sorted.length; const rowsPerPage = domainStrengthPaginationState.rowsPerPage === 'all' ? totalRows : domainStrengthPaginationState.rowsPerPage; const totalPages = rowsPerPage > 0 ? Math.ceil(totalRows / rowsPerPage) : 1; const currentPage = Math.min(Math.max(1, domainStrengthPaginationState.currentPage), totalPages); domainStrengthPaginationState.currentPage = currentPage; const startIdx = rowsPerPage === 'all' ? 0 : (currentPage - 1) * rowsPerPage; const endIdx = rowsPerPage === 'all' ? totalRows : Math.min(startIdx + rowsPerPage, totalRows); const paginatedRows = sorted.slice(startIdx, endIdx); // Determine visible rows based on expand/collapse state // Always show your site (first row), then top 2 competitors = 3 rows total // If your site is not in paginated rows, show first 3 rows const selfRowIndex = paginatedRows.findIndex((it) => String(it?.domain || '') === selfDomain); let visibleRows; if (domainStrengthExpanded) { visibleRows = paginatedRows; } else { if (selfRowIndex >= 0) { // Your site is in the paginated rows // Show your site + next 2 rows (competitors) visibleRows = paginatedRows.slice(0, Math.min(selfRowIndex + 3, paginatedRows.length)); } else { // Your site not in current page, just show first 3 visibleRows = paginatedRows.slice(0, 3); } } const hiddenRowsCount = paginatedRows.length - visibleRows.length; tbody.innerHTML = ''; for (const it of visibleRows) { const latest = it?.latest || null; const delta = it?.trend?.deltaLatest ?? null; const points = Array.isArray(it?.trend?.points) ? it.trend.points : []; const last12 = points.slice(-12); const labels = last12.map((p) => p.date); const scores = last12.map((p) => p.score); const safeId = String(it?.domain || '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, ''); const canvasId = `domain-strength-spark-${safeId || Math.random().toString(36).slice(2)}`; const arrow = delta > 0.5 ? '↑' : (delta < -0.5 ? '↓' : '•'); const cls = delta > 0.5 ? 'kpi-trend-up' : (delta < -0.5 ? 'kpi-trend-down' : 'kpi-trend-flat'); const changeTitle = delta === null ? 'No previous snapshot yet' : `Change vs last snapshot: ${formatDelta(delta)}`; // Check if this is the self domain row const isSelfDomain = String(it?.domain || '') === selfDomain; const rowStyle = isSelfDomain ? 'font-weight: 700; background-color: #fefce8;' : ''; const tr = document.createElement('tr'); if (isSelfDomain) { tr.id = 'domain-strength-self-row'; tr.style.fontWeight = '700'; tr.style.backgroundColor = '#fefce8'; } // Create clickable domain link const domainName = it?.domain || ''; const displayName = it?.label || it?.domain || '—'; const domainLink = domainName ? `${displayName}` : displayName; // Get domain type and competitor flag - use domain_type directly, fallback to segment, only default to 'unmapped' if both are missing const domainType = it?.domain_type || it?.segment || 'unmapped'; const isCompetitor = it?.isCompetitor === true; const normalizedDomain = normalizeDomainForStrength(domainName); tr.innerHTML = ` ${domainLink} ${domainType} ${latest ? formatDomainStrengthScore(latest.score) : '—'} ${latest ? renderDomainStrengthBandPill(latest.band) : '—'} ${latest ? formatEtvDollars(latest.organicEtv) : '—'} ${latest ? formatIntegerOrDash(latest.top10Keywords) : '—'} ${delta === null ? '—' : `${arrow} ${formatDelta(delta)}`} `; tbody.appendChild(tr); // Add domain type dropdown to the Domain type cell const domainTypeCell = tr.querySelector(`td:nth-child(2)`); const domainTypeDisplay = domainTypeCell.querySelector(`[data-domain-type-display="${normalizedDomain}"]`); if (domainTypeDisplay && domainTypeCell) { domainTypeCell.innerHTML = ''; const domainTypeSelect = document.createElement('select'); domainTypeSelect.style.padding = '0.4rem 0.6rem'; domainTypeSelect.style.border = '1px solid #cbd5e1'; domainTypeSelect.style.borderRadius = '4px'; domainTypeSelect.style.fontSize = '0.85rem'; domainTypeSelect.style.background = 'white'; domainTypeSelect.style.cursor = 'pointer'; domainTypeSelect.style.minWidth = '120px'; domainTypeSelect.dataset.domain = normalizedDomain; const domainTypes = [ { value: 'unmapped', label: 'Unmapped' }, { value: 'your_site', label: 'Your site' }, { value: 'platform', label: 'Platform' }, { value: 'directory', label: 'Directory' }, { value: 'publisher', label: 'Publisher' }, { value: 'vendor', label: 'Vendor' }, { value: 'institution', label: 'Institution' }, { value: 'government', label: 'Government' }, { value: 'site', label: 'Site' } ]; // Get the actual domain_type from the data - check both domain_type and segment fields let actualDomainType = 'unmapped'; if (it?.domain_type && typeof it.domain_type === 'string' && it.domain_type.trim()) { actualDomainType = it.domain_type.trim(); } else if (it?.segment && typeof it.segment === 'string' && it.segment.trim()) { actualDomainType = it.segment.trim(); } // Ensure the value matches one of the valid options const validValues = domainTypes.map(dt => dt.value); if (!validValues.includes(actualDomainType)) { actualDomainType = 'unmapped'; } // Create options first, then set the value domainTypes.forEach(({ value, label }) => { const option = document.createElement('option'); option.value = value; option.textContent = label; if (value === actualDomainType) { option.selected = true; } domainTypeSelect.appendChild(option); }); // Explicitly set the value after options are added domainTypeSelect.value = actualDomainType; domainTypeSelect.addEventListener('change', async (e) => { const newType = e.target.value; const domain = e.target.dataset.domain; try { const resp = await fetch(apiUrl('/api/domain-strength/update-domain'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain, domain_type: newType }) }); if (resp.ok) { __domainMetadataCache.delete(domain); // Refresh the table to show updated values await renderDomainStrengthSection(); } else { e.target.value = domainType; alert('Failed to update domain type. Please try again.'); } } catch (err) { e.target.value = domainType; alert('Error updating domain type: ' + err.message); } }); domainTypeCell.appendChild(domainTypeSelect); } // Add competitor checkbox to the Competitor cell const competitorCell = tr.querySelector(`td:nth-child(9)`); if (competitorCell) { const competitorCheckbox = document.createElement('input'); competitorCheckbox.type = 'checkbox'; competitorCheckbox.checked = isCompetitor; competitorCheckbox.style.cursor = 'pointer'; competitorCheckbox.dataset.domain = normalizedDomain; competitorCheckbox.addEventListener('change', async (e) => { const isComp = e.target.checked; const domain = e.target.dataset.domain; try { const resp = await fetch(apiUrl('/api/domain-strength/update-domain'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain, is_competitor: isComp }) }); if (resp.ok) { __domainMetadataCache.delete(domain); // Refresh the table to show updated values await renderDomainStrengthSection(); } else { e.target.checked = !isComp; alert('Failed to update competitor flag. Please try again.'); } } catch (err) { e.target.checked = !isComp; alert('Error updating competitor flag: ' + err.message); } }); competitorCell.appendChild(competitorCheckbox); } const canvas = document.getElementById(canvasId); renderDomainStrengthSparklineChart(canvas, labels, scores); } // Add expand/collapse row if there are hidden rows if (!domainStrengthExpanded && hiddenRowsCount > 0) { const expandRow = document.createElement('tr'); expandRow.id = 'domain-strength-expand-row'; expandRow.style.cursor = 'pointer'; expandRow.style.backgroundColor = '#f8fafc'; expandRow.style.borderTop = '2px solid #e2e8f0'; expandRow.innerHTML = ` Show ${hiddenRowsCount} more row${hiddenRowsCount !== 1 ? 's' : ''} `; expandRow.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); domainStrengthExpanded = true; renderDomainStrengthSection(); }); expandRow.addEventListener('mouseenter', () => { expandRow.style.backgroundColor = '#e0f2fe'; }); expandRow.addEventListener('mouseleave', () => { expandRow.style.backgroundColor = '#f8fafc'; }); tbody.appendChild(expandRow); } else if (domainStrengthExpanded && paginatedRows.length > 3) { const collapseRow = document.createElement('tr'); collapseRow.id = 'domain-strength-collapse-row'; collapseRow.style.cursor = 'pointer'; collapseRow.style.backgroundColor = '#f8fafc'; collapseRow.style.borderTop = '2px solid #e2e8f0'; collapseRow.innerHTML = ` Show less `; collapseRow.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); domainStrengthExpanded = false; renderDomainStrengthSection(); }); collapseRow.addEventListener('mouseenter', () => { collapseRow.style.backgroundColor = '#e0f2fe'; }); collapseRow.addEventListener('mouseleave', () => { collapseRow.style.backgroundColor = '#f8fafc'; }); tbody.appendChild(collapseRow); } // Update sort indicators document.querySelectorAll('#domain-strength-table th.sortable').forEach(th => { th.classList.remove('sort-asc', 'sort-desc'); if (th.dataset.sort === domainStrengthSortState.column) { th.classList.add(`sort-${domainStrengthSortState.direction}`); } }); // Update pagination controls const paginationInfo = document.getElementById('domain-strength-pagination-info'); const prevBtn = document.getElementById('domain-strength-pagination-prev'); const nextBtn = document.getElementById('domain-strength-pagination-next'); const rowsPerPageSelect = document.getElementById('domain-strength-rows-per-page'); if (paginationInfo) { paginationInfo.textContent = totalPages > 1 ? `Page ${currentPage} of ${totalPages} • Showing ${startIdx + 1}-${endIdx} of ${totalRows}` : `Total: ${totalRows}`; } if (prevBtn) { prevBtn.disabled = currentPage === 1; prevBtn.style.background = currentPage === 1 ? '#f1f5f9' : 'white'; prevBtn.style.cursor = currentPage === 1 ? 'not-allowed' : 'pointer'; prevBtn.style.color = currentPage === 1 ? '#94a3b8' : '#475569'; } if (nextBtn) { nextBtn.disabled = currentPage === totalPages; nextBtn.style.background = currentPage === totalPages ? '#f1f5f9' : 'white'; nextBtn.style.cursor = currentPage === totalPages ? 'not-allowed' : 'pointer'; nextBtn.style.color = currentPage === totalPages ? '#94a3b8' : '#475569'; } if (rowsPerPageSelect) { rowsPerPageSelect.value = domainStrengthPaginationState.rowsPerPage; } // Re-wire sorting and pagination after render wireDomainStrengthSorting(); wireDomainStrengthPagination(); } async function runDomainStrengthSnapshot() { debugLog('[DomainStrength] runDomainStrengthSnapshot() called', 'info'); const statusEl = document.getElementById('domain-strength-run-status'); const btn = document.getElementById('domain-strength-run-btn'); const overlay = document.getElementById('domain-strength-overlay'); const overlayStatus = document.getElementById('domain-strength-overlay-status'); // Don't send core domains - API will handle alanranger.com + pending queue // This ensures cost control: only fetch domains that don't have snapshots this month const domains = []; // Show overlay if (overlay) { overlay.classList.add('show'); } if (overlayStatus) { overlayStatus.textContent = `Fetching alanranger.com + up to 100 pending domains (skipping domains already processed this month)...`; } if (btn) { btn.disabled = true; btn.textContent = 'Running…'; } if (statusEl) statusEl.textContent = `Running snapshot (alanranger.com + pending queue)...`; try { const apiEndpoint = apiUrl('/api/domain-strength/snapshot'); debugLog(`[DomainStrength] Calling API: ${apiEndpoint}`, 'info'); debugLog(`[DomainStrength] Request: mode=run, includePending=true`, 'info'); if (overlayStatus) { overlayStatus.textContent = `Calling DataForSEO API (alanranger.com + pending domains)...`; } const resp = await fetch(apiEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'run', domains: [], includePending: true }) }); debugLog(`[DomainStrength] API response status: ${resp.status} ${resp.statusText}`, 'info'); if (overlayStatus) { overlayStatus.textContent = 'Processing response and saving to database...'; } let json; try { const responseText = await resp.text(); debugLog(`[DomainStrength] API response text (first 200 chars): ${responseText.substring(0, 200)}`, 'info'); json = JSON.parse(responseText); } catch (parseError) { debugLog(`[DomainStrength] Failed to parse JSON response: ${parseError.message}`, 'error'); throw new Error(`API returned invalid JSON (status ${resp.status}). Response may be an error page.`); } debugLog(`[DomainStrength] API response: status=${json?.status}, inserted=${json?.inserted}, snapshot_date=${json?.snapshot_date}, domains_processed=${json?.domains_processed}`, 'info'); debugLog(`[DomainStrength] Debug info: ${JSON.stringify(json?.debug || {})}`, 'info'); // Update overlay message with actual domain count from API (includes pending domains) const actualDomainCount = json?.domains_processed || 1; if (overlayStatus) { overlayStatus.textContent = `Calling DataForSEO API for ${actualDomainCount} domains (alanranger.com + ${actualDomainCount - 1} pending)...`; } if (json?.status !== 'ok') { const errorMsg = json?.message || json?.details || 'Snapshot failed'; debugLog(`[DomainStrength] API returned error: ${errorMsg}`, 'error'); throw new Error(errorMsg); } debugLog(`[DomainStrength] ✓ Snapshot successful: ${json.inserted || 0} domains saved (date: ${json.snapshot_date})`, 'success'); // Fetch remaining pending count let remainingPending = 0; try { const pendingResp = await fetch(apiUrl('/api/domain-strength/pending-count')); if (pendingResp.ok) { const pendingData = await pendingResp.json(); remainingPending = pendingData.count || 0; } } catch (e) { debugLog(`[DomainStrength] Could not fetch pending count: ${e.message}`, 'warn'); } // Hide progress overlay if (overlay) { overlay.classList.remove('show'); } // Show completion modal const completionModal = document.getElementById('domain-strength-completion-modal'); const completionStats = document.getElementById('domain-strength-completion-stats'); if (completionModal && completionStats) { const domainsProcessed = json.domains_processed || 0; const domainsInserted = json.inserted || 0; const domainsFetched = json.fetched || 0; completionStats.innerHTML = `
    Domains Processed: ${domainsProcessed}
    ${domainsFetched} fetched from DataForSEO • ${domainsInserted} snapshots saved
    Remaining in Queue: ${remainingPending}
    ${remainingPending > 0 ? '
    Run snapshot again to process more domains
    ' : '
    All domains have been processed!
    '}
    `; completionModal.style.display = 'flex'; // Wire up close button const closeBtn = document.getElementById('domain-strength-completion-close'); if (closeBtn) { closeBtn.onclick = () => { completionModal.style.display = 'none'; }; } } if (statusEl) statusEl.textContent = `✓ Snapshot saved for ${json.inserted || 0} domains (date: ${json.snapshot_date}).`; showStatus(`✓ Domain strength snapshot saved (${json.snapshot_date})`, 'success'); debugLog('[DomainStrength] Refreshing domain strength section...', 'info'); await renderDomainStrengthSection(); debugLog('[DomainStrength] ✓ Domain strength section refreshed', 'success'); // Dashboard: refresh live dials/cards after a domain strength snapshot if (typeof window.renderDashboardTab === 'function') { try { window.renderDashboardTab(); } catch {} } } catch (e) { const msg = e?.message || String(e); debugLog(`[DomainStrength] ✗ Snapshot error: ${msg}`, 'error'); if (overlayStatus) { overlayStatus.textContent = `✗ Error: ${msg}`; } if (statusEl) statusEl.textContent = `✗ Snapshot failed: ${msg}`; showStatus(`✗ Domain strength snapshot failed: ${msg}`, 'error'); // Keep overlay visible for a moment to show error await new Promise(resolve => setTimeout(resolve, 2000)); // Hide overlay on error if (overlay) { overlay.classList.remove('show'); } } finally { if (btn) { btn.disabled = false; btn.textContent = 'Run Domain Strength Snapshot (Google)'; } } } function wireDomainStrengthButton() { const btn = document.getElementById('domain-strength-run-btn'); if (!btn) return; const newBtn = btn.cloneNode(true); btn.parentNode.replaceChild(newBtn, btn); newBtn.addEventListener('click', () => { runDomainStrengthSnapshot(); }); // Render current view (if history exists) once the ranking module has data try { renderDomainStrengthSection(); } catch { // ignore } } function wireDomainStrengthSorting() { const sortableHeaders = document.querySelectorAll('#domain-strength-table th.sortable'); if (!sortableHeaders || sortableHeaders.length === 0) return; // Remove existing listeners by cloning (preserve innerHTML structure) sortableHeaders.forEach(th => { if (th.dataset.sortWired === 'true') { const newTh = th.cloneNode(true); th.parentNode.replaceChild(newTh, th); } }); // Re-query after cloning to get fresh elements const freshHeaders = document.querySelectorAll('#domain-strength-table th.sortable'); freshHeaders.forEach(th => { th.dataset.sortWired = 'true'; // Use a named function to make debugging easier const handleSortClick = function(e) { e.stopPropagation(); e.preventDefault(); const column = this.dataset.sort; if (!column) { console.warn('[DomainStrength] No sort column found on clicked header'); return; } console.log('[DomainStrength] Sort clicked:', column, 'Current state:', domainStrengthSortState); if (domainStrengthSortState.column === column) { // Toggle direction if clicking the same column domainStrengthSortState.direction = domainStrengthSortState.direction === 'asc' ? 'desc' : 'asc'; } else { // New column, start with ascending domainStrengthSortState.column = column; domainStrengthSortState.direction = 'asc'; } console.log('[DomainStrength] New sort state:', domainStrengthSortState); domainStrengthPaginationState.currentPage = 1; renderDomainStrengthSection(); }; th.addEventListener('click', handleSortClick); }); console.log('[DomainStrength] Wired', freshHeaders.length, 'sortable headers'); } function wireDomainStrengthPagination() { const prevBtn = document.getElementById('domain-strength-pagination-prev'); const nextBtn = document.getElementById('domain-strength-pagination-next'); const rowsPerPageSelect = document.getElementById('domain-strength-rows-per-page'); if (prevBtn) { const newPrevBtn = prevBtn.cloneNode(true); prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn); newPrevBtn.addEventListener('click', () => { if (domainStrengthPaginationState.currentPage > 1) { domainStrengthPaginationState.currentPage--; renderDomainStrengthSection(); } }); } if (nextBtn) { const newNextBtn = nextBtn.cloneNode(true); nextBtn.parentNode.replaceChild(newNextBtn, nextBtn); newNextBtn.addEventListener('click', () => { // Re-fetch items to get accurate total fetchDomainStrengthOverview().then(items => { const engine = 'google'; const filtered = items.filter((it) => String(it?.searchEngine || '').toLowerCase() === engine); const totalRows = filtered.length; const rowsPerPage = domainStrengthPaginationState.rowsPerPage === 'all' ? totalRows : domainStrengthPaginationState.rowsPerPage; const totalPages = rowsPerPage > 0 ? Math.ceil(totalRows / rowsPerPage) : 1; if (domainStrengthPaginationState.currentPage < totalPages) { domainStrengthPaginationState.currentPage++; renderDomainStrengthSection(); } }).catch(() => { // If fetch fails, just increment and re-render domainStrengthPaginationState.currentPage++; renderDomainStrengthSection(); }); }); } if (rowsPerPageSelect) { const newSelect = rowsPerPageSelect.cloneNode(true); rowsPerPageSelect.parentNode.replaceChild(newSelect, rowsPerPageSelect); newSelect.value = domainStrengthPaginationState.rowsPerPage; newSelect.addEventListener('change', (e) => { const value = e.target.value; domainStrengthPaginationState.rowsPerPage = value === 'all' ? 'all' : parseInt(value, 10); domainStrengthPaginationState.currentPage = 1; renderDomainStrengthSection(); }); } } // Wire button immediately wireRankingAiButton(); // Wire domain strength button immediately if (typeof wireDomainStrengthButton === 'function') wireDomainStrengthButton(); wireRankingFilters(); wireRankingSorting(); wireRankingPagination(); wireDomainStrengthSorting(); wireDomainStrengthPagination(); // Make functions globally available for panel switching window.wireRankingAiButton = wireRankingAiButton; window.wireDomainStrengthButton = wireDomainStrengthButton; window.wireRankingFilters = wireRankingFilters; window.wireRankingSorting = wireRankingSorting; window.wireDomainStrengthSorting = wireDomainStrengthSorting; window.wireDomainStrengthPagination = wireDomainStrengthPagination; function wireRankingFilters() { const segmentFilter = document.getElementById("ranking-filter-segment"); const rankFilter = document.getElementById("ranking-filter-rank"); const volumeFilter = document.getElementById("ranking-filter-volume"); const ctrFilter = document.getElementById("ranking-filter-ctr"); const aiOverviewFilter = document.getElementById("ranking-filter-ai-overview"); const aiCitationFilter = document.getElementById("ranking-filter-ai-citation"); const opportunityFilter = document.getElementById("ranking-filter-opportunity"); const pageTypeFilter = document.getElementById("ranking-filter-page-type"); const serpFeaturesFilter = document.getElementById("ranking-filter-serp-features"); const optimisationStatusFilter = document.getElementById("ranking-filter-optimisation-status"); const keywordFilter = document.getElementById("ranking-filter-keyword"); const clearBtn = document.getElementById("ranking-filter-clear"); // Update active state styling for filters const updateFilterActiveStates = () => { const filterControls = [ { el: segmentFilter, value: rankingFilterState.segment }, { el: rankFilter, value: rankingFilterState.rank }, { el: volumeFilter, value: rankingFilterState.volume }, { el: ctrFilter, value: rankingFilterState.ctr }, { el: aiOverviewFilter, value: rankingFilterState.aiOverview }, { el: aiCitationFilter, value: rankingFilterState.aiCitation }, { el: opportunityFilter, value: rankingFilterState.opportunity }, { el: pageTypeFilter, value: rankingFilterState.pageType }, { el: serpFeaturesFilter, value: rankingFilterState.serpFeatures }, { el: optimisationStatusFilter, value: rankingFilterState.optimisationStatus } ]; filterControls.forEach(({ el, value }) => { if (el) { if (value && value !== 'all') { el.classList.add('filter-active'); } else { el.classList.remove('filter-active'); } } }); // Handle keyword filter separately (active if not empty) if (keywordFilter) { if (rankingFilterState.keyword && rankingFilterState.keyword.trim() !== '') { keywordFilter.classList.add('filter-active'); } else { keywordFilter.classList.remove('filter-active'); } } }; const minOpportunityInput = document.getElementById("ranking-filter-min-opportunity"); const minOpportunityNote = document.getElementById("ranking-filter-min-opportunity-note"); const updateFilters = () => { if (segmentFilter) rankingFilterState.segment = segmentFilter.value; if (rankFilter) rankingFilterState.rank = rankFilter.value; if (volumeFilter) rankingFilterState.volume = volumeFilter.value; if (ctrFilter) rankingFilterState.ctr = ctrFilter.value; if (aiOverviewFilter) rankingFilterState.aiOverview = aiOverviewFilter.value; if (aiCitationFilter) rankingFilterState.aiCitation = aiCitationFilter.value; if (opportunityFilter) rankingFilterState.opportunity = opportunityFilter.value; if (pageTypeFilter) rankingFilterState.pageType = pageTypeFilter.value; if (serpFeaturesFilter) rankingFilterState.serpFeatures = serpFeaturesFilter.value; if (optimisationStatusFilter) rankingFilterState.optimisationStatus = optimisationStatusFilter.value; if (keywordFilter) rankingFilterState.keyword = keywordFilter.value.trim(); // Min opportunity filter if (minOpportunityInput) { const minOppValue = minOpportunityInput.value.trim(); if (minOppValue === '') { rankingFilterState.minOpportunity = null; if (minOpportunityNote) minOpportunityNote.style.display = 'none'; } else { const numValue = Number(minOppValue); if (Number.isFinite(numValue) && numValue >= 0 && numValue <= 100) { rankingFilterState.minOpportunity = numValue; if (minOpportunityNote) minOpportunityNote.style.display = 'block'; } else { rankingFilterState.minOpportunity = null; if (minOpportunityNote) minOpportunityNote.style.display = 'none'; } } } // Update active states updateFilterActiveStates(); rankingPaginationState.currentPage = 1; // Reset to first page on filter change renderRankingAiTab(); }; // Debounce function for keyword filter to improve performance let keywordFilterTimeout = null; const debouncedUpdateFilters = () => { // Update the keyword value immediately for active state if (keywordFilter) { rankingFilterState.keyword = keywordFilter.value.trim(); updateFilterActiveStates(); } // Clear existing timeout if (keywordFilterTimeout) { clearTimeout(keywordFilterTimeout); } // Set new timeout to actually apply the filter after user stops typing keywordFilterTimeout = setTimeout(() => { updateFilters(); keywordFilterTimeout = null; }, 300); // 300ms delay }; if (segmentFilter) segmentFilter.addEventListener("change", updateFilters); if (rankFilter) rankFilter.addEventListener("change", updateFilters); if (volumeFilter) volumeFilter.addEventListener("change", updateFilters); if (ctrFilter) ctrFilter.addEventListener("change", updateFilters); if (aiOverviewFilter) aiOverviewFilter.addEventListener("change", updateFilters); if (aiCitationFilter) aiCitationFilter.addEventListener("change", updateFilters); if (opportunityFilter) opportunityFilter.addEventListener("change", updateFilters); if (pageTypeFilter) pageTypeFilter.addEventListener("change", updateFilters); if (serpFeaturesFilter) serpFeaturesFilter.addEventListener("change", updateFilters); if (optimisationStatusFilter) optimisationStatusFilter.addEventListener("change", updateFilters); if (keywordFilter) keywordFilter.addEventListener("input", debouncedUpdateFilters); if (minOpportunityInput) { minOpportunityInput.addEventListener("input", debouncedUpdateFilters); minOpportunityInput.addEventListener("blur", updateFilters); } if (clearBtn) { clearBtn.addEventListener("click", () => { // Cancel any pending debounced keyword filter updates if (keywordFilterTimeout) { clearTimeout(keywordFilterTimeout); keywordFilterTimeout = null; } rankingFilterState = { segment: 'all', rank: 'all', volume: 'all', ctr: 'all', opportunity: 'all', aiOverview: 'all', aiCitation: 'all', pageType: 'all', serpFeatures: 'all', optimisationStatus: 'all', keyword: '', minOpportunity: null }; activePreset = null; // Clear active preset rankingPriorityFilter = null; // Clear priority matrix filter selectedKeywordId = null; // Clear selected keyword if (segmentFilter) segmentFilter.value = 'all'; if (rankFilter) rankFilter.value = 'all'; if (volumeFilter) volumeFilter.value = 'all'; if (ctrFilter) ctrFilter.value = 'all'; if (aiOverviewFilter) aiOverviewFilter.value = 'all'; if (aiCitationFilter) aiCitationFilter.value = 'all'; if (opportunityFilter) opportunityFilter.value = 'all'; if (pageTypeFilter) pageTypeFilter.value = 'all'; if (serpFeaturesFilter) serpFeaturesFilter.value = 'all'; if (optimisationStatusFilter) optimisationStatusFilter.value = 'all'; if (keywordFilter) keywordFilter.value = ''; if (minOpportunityInput) { minOpportunityInput.value = ''; if (minOpportunityNote) minOpportunityNote.style.display = 'none'; } updateFilterActiveStates(); if (typeof updatePresetButtonActiveStates === 'function') { updatePresetButtonActiveStates(); } if (typeof renderPresetCriteriaChips === 'function') { renderPresetCriteriaChips(); } rankingPaginationState.currentPage = 1; // Reset to first page on clear rankingSortState.column = 'opportunityScore'; // Reset sort rankingSortState.direction = 'desc'; renderRankingAiTab(); }); } // Default filter state (single source of truth) const DEFAULT_FILTERS = { segment: 'all', rank: 'all', volume: 'all', ctr: 'all', opportunity: 'all', minOpportunity: null, aiOverview: 'all', aiCitation: 'all', pageType: 'all', serpFeatures: 'all', keyword: '' }; // Default sort state const DEFAULT_SORT = { column: 'opportunityScore', direction: 'desc' }; // Preset definitions (data-driven) const PRESETS = { 'all': { label: 'All keywords', filters: { ...DEFAULT_FILTERS }, sort: { ...DEFAULT_SORT } }, 'high-impact-money': { label: 'High-impact money', filters: { ...DEFAULT_FILTERS, segment: 'money', volume: 'high', rank: '11-20', minOpportunity: 65 }, sort: { column: 'opportunityScore', direction: 'desc' } }, 'ai-overview-not-cited': { label: 'AI Overview, not cited', filters: { ...DEFAULT_FILTERS, aiOverview: 'has', aiCitation: 'not-cited', minOpportunity: 50 }, sort: { column: 'volume', direction: 'desc' } }, 'brand-safety': { label: 'Brand safety', filters: { ...DEFAULT_FILTERS, segment: 'brand', rank: 'not-top3' }, sort: { column: 'rank', direction: 'asc' } }, 'education-growth': { label: 'Blog opportunities', filters: { ...DEFAULT_FILTERS, pageType: 'Blog', rank: 'not-top3', minOpportunity: 30 }, sort: { column: 'opportunityScore', direction: 'desc' } }, 'local-visibility': { label: 'Local visibility', filters: { ...DEFAULT_FILTERS, pageType: 'GBP', rank: 'not-top3', minOpportunity: 30 }, sort: { column: 'opportunityScore', direction: 'desc' } }, 'top-10-opportunities': { label: 'Top 10 opportunities', filters: { ...DEFAULT_FILTERS, rank: 'not-top3', minOpportunity: 50 }, sort: { column: 'opportunityScore', direction: 'desc' }, rowsPerPage: 10 } }; // Preset button handlers function applyPreset(presetKey) { // Cancel any pending debounced keyword filter updates if (keywordFilterTimeout) { clearTimeout(keywordFilterTimeout); keywordFilterTimeout = null; } // Clear priority matrix filter and selected keyword rankingPriorityFilter = null; selectedKeywordId = null; // Get preset definition const preset = PRESETS[presetKey]; if (!preset) { console.warn(`Unknown preset: ${presetKey}`); return; } // HARD RESET: Apply preset filters (don't merge with existing state) rankingFilterState = { ...preset.filters }; rankingSortState = { ...preset.sort }; // Set rows per page if specified in preset if (preset.rowsPerPage) { const rowsPerPageSelect = document.getElementById('ranking-rows-per-page'); if (rowsPerPageSelect) { rowsPerPageSelect.value = preset.rowsPerPage; rankingPaginationState.rowsPerPage = preset.rowsPerPage; } } // Set active preset (null for 'all') activePreset = presetKey === 'all' ? null : presetKey; // Update UI elements to match filter state if (segmentFilter) segmentFilter.value = rankingFilterState.segment; if (rankFilter) rankFilter.value = rankingFilterState.rank; if (volumeFilter) volumeFilter.value = rankingFilterState.volume; if (ctrFilter) ctrFilter.value = rankingFilterState.ctr; if (aiOverviewFilter) aiOverviewFilter.value = rankingFilterState.aiOverview; if (aiCitationFilter) aiCitationFilter.value = rankingFilterState.aiCitation; if (opportunityFilter) opportunityFilter.value = rankingFilterState.opportunity; if (pageTypeFilter) pageTypeFilter.value = rankingFilterState.pageType; if (serpFeaturesFilter) serpFeaturesFilter.value = rankingFilterState.serpFeatures; if (keywordFilter) keywordFilter.value = rankingFilterState.keyword; // Update minOpportunity input const minOppInput = document.getElementById("ranking-filter-min-opportunity"); const minOppNote = document.getElementById("ranking-filter-min-opportunity-note"); if (minOppInput) { if (rankingFilterState.minOpportunity != null) { minOppInput.value = String(rankingFilterState.minOpportunity); if (minOppNote) minOppNote.style.display = 'block'; } else { minOppInput.value = ''; if (minOppNote) minOppNote.style.display = 'none'; } } updateFilterActiveStates(); updatePresetButtonActiveStates(); rankingPaginationState.currentPage = 1; renderPresetCriteriaChips(); renderRankingAiTab(); } // Update preset button active states function updatePresetButtonActiveStates() { const presetButtons = document.querySelectorAll('.ranking-preset-btn'); presetButtons.forEach(btn => { const presetKey = btn.getAttribute('data-preset'); if (activePreset === presetKey || (presetKey === 'all' && activePreset === null)) { btn.classList.add('preset-active'); btn.style.background = '#047857'; // Darker green background for active (emerald-800) btn.style.borderColor = '#065f46'; // Darker border btn.style.color = '#ffffff'; btn.style.fontWeight = '600'; } else { btn.classList.remove('preset-active'); btn.style.background = 'rgb(229, 255, 204)'; btn.style.borderColor = '#cbd5e1'; btn.style.color = '#475569'; btn.style.fontWeight = '500'; } }); } // Get active criteria chips based on filter state function getActiveCriteriaChips() { const chips = []; if (rankingFilterState.segment !== 'all') { const segmentLabel = rankingFilterState.segment.charAt(0).toUpperCase() + rankingFilterState.segment.slice(1); chips.push({ label: `Segment: ${segmentLabel}`, onRemove: () => { rankingFilterState.segment = 'all'; const segmentFilter = document.getElementById("ranking-filter-segment"); if (segmentFilter) segmentFilter.value = 'all'; activePreset = null; updateFilters(); } }); } if (rankingFilterState.rank !== 'all') { let rankLabel = ''; if (rankingFilterState.rank === 'top3') rankLabel = 'Top 3'; else if (rankingFilterState.rank === '4-10') rankLabel = '4–10'; else if (rankingFilterState.rank === '11-20') rankLabel = '11–20'; else if (rankingFilterState.rank === '21+') rankLabel = '21+ / Not ranked'; else if (rankingFilterState.rank === 'not-top3') rankLabel = 'Not top 3'; chips.push({ label: `Best rank: ${rankLabel}`, onRemove: () => { rankingFilterState.rank = 'all'; const rankFilter = document.getElementById("ranking-filter-rank"); if (rankFilter) rankFilter.value = 'all'; activePreset = null; updateFilters(); } }); } if (rankingFilterState.pageType && rankingFilterState.pageType !== 'all') { chips.push({ label: `Page type: ${rankingFilterState.pageType}`, onRemove: () => { rankingFilterState.pageType = 'all'; const pageTypeFilter = document.getElementById("ranking-filter-page-type"); if (pageTypeFilter) pageTypeFilter.value = 'all'; activePreset = null; updateFilters(); } }); } if (rankingFilterState.volume !== 'all') { const volumeLabel = rankingFilterState.volume.charAt(0).toUpperCase() + rankingFilterState.volume.slice(1); chips.push({ label: `Search volume: ${volumeLabel}`, onRemove: () => { rankingFilterState.volume = 'all'; const volumeFilter = document.getElementById("ranking-filter-volume"); if (volumeFilter) volumeFilter.value = 'all'; activePreset = null; updateFilters(); } }); } if (rankingFilterState.aiOverview !== 'all') { const aiLabel = rankingFilterState.aiOverview === 'has' ? 'On' : 'Off'; chips.push({ label: `AI Overview: ${aiLabel}`, onRemove: () => { rankingFilterState.aiOverview = 'all'; const aiOverviewFilter = document.getElementById("ranking-filter-ai-overview"); if (aiOverviewFilter) aiOverviewFilter.value = 'all'; activePreset = null; updateFilters(); } }); } if (rankingFilterState.aiCitation !== 'all') { const citationLabel = rankingFilterState.aiCitation === 'cited' ? 'Cited' : 'Not cited'; chips.push({ label: `AI citation: ${citationLabel}`, onRemove: () => { rankingFilterState.aiCitation = 'all'; const aiCitationFilter = document.getElementById("ranking-filter-ai-citation"); if (aiCitationFilter) aiCitationFilter.value = 'all'; activePreset = null; updateFilters(); } }); } if (rankingFilterState.minOpportunity != null) { chips.push({ label: `Min opportunity: ≥ ${rankingFilterState.minOpportunity}`, onRemove: () => { rankingFilterState.minOpportunity = null; const minOppInput = document.getElementById("ranking-filter-min-opportunity"); const minOppNote = document.getElementById("ranking-filter-min-opportunity-note"); if (minOppInput) minOppInput.value = ''; if (minOppNote) minOppNote.style.display = 'none'; activePreset = null; updateFilters(); } }); } // Add sort chip if not default if (rankingSortState.column && (rankingSortState.column !== 'opportunityScore' || rankingSortState.direction !== 'desc')) { const sortLabel = rankingSortState.column === 'rank' ? 'Rank' : rankingSortState.column === 'volume' ? 'Volume' : rankingSortState.column === 'opportunityScore' ? 'Opportunity' : rankingSortState.column; const sortDir = rankingSortState.direction === 'asc' ? '↑' : '↓'; chips.push({ label: `Sort: ${sortLabel} ${sortDir}`, onRemove: () => { rankingSortState.column = 'opportunityScore'; rankingSortState.direction = 'desc'; activePreset = null; renderRankingAiTab(); } }); } return chips; } // Render criteria chips function renderPresetCriteriaChips() { const chipsContainer = document.getElementById('ranking-preset-criteria-chips'); if (!chipsContainer) return; const chips = getActiveCriteriaChips(); if (chips.length === 0 || activePreset === null) { chipsContainer.style.display = 'none'; return; } chipsContainer.style.display = 'block'; const chipsInner = chipsContainer.querySelector('div'); if (!chipsInner) return; chipsInner.innerHTML = chips.map(chip => { // Create a wrapper function to handle the removal and update const removeHandler = () => { chip.onRemove(); updatePresetButtonActiveStates(); renderPresetCriteriaChips(); }; return ` ${chip.label} `; }).join(''); } // Wire up preset buttons (re-wire on each call to handle DOM updates) function wirePresetButtons() { // Remove existing listeners by cloning buttons const presetButtons = document.querySelectorAll('.ranking-preset-btn'); presetButtons.forEach(btn => { const newBtn = btn.cloneNode(true); btn.parentNode.replaceChild(newBtn, btn); }); // Re-query to get fresh elements const freshPresetButtons = document.querySelectorAll('.ranking-preset-btn'); freshPresetButtons.forEach(btn => { btn.addEventListener('click', () => { const presetKey = btn.getAttribute('data-preset'); if (presetKey) { applyPreset(presetKey); } }); }); } // Wire preset buttons immediately wirePresetButtons(); // Make wirePresetButtons globally available window.wirePresetButtons = wirePresetButtons; // Initialize active states on page load updateFilterActiveStates(); } function wireRankingSorting() { // Find all sortable headers const sortableHeaders = document.querySelectorAll('.ranking-table th.sortable'); if (!sortableHeaders || sortableHeaders.length === 0) { console.log('[Sort] No sortable headers found'); return; } console.log('[Sort] Found', sortableHeaders.length, 'sortable headers'); // Remove any existing data attribute markers sortableHeaders.forEach(th => { if (th.dataset.sortWired === 'true') { // Clone to remove all listeners const newTh = th.cloneNode(true); th.parentNode.replaceChild(newTh, th); } }); // Re-query after cloning const freshHeaders = document.querySelectorAll('.ranking-table th.sortable'); // Attach listeners directly to each header freshHeaders.forEach(th => { th.dataset.sortWired = 'true'; // Mark as wired th.addEventListener('click', function(e) { e.stopPropagation(); const column = this.dataset.sort; if (!column) { console.log('[Sort] No sort column found'); return; } console.log('[Sort] Clicked column:', column, 'Current:', rankingSortState.column, rankingSortState.direction); if (rankingSortState.column === column) { // Toggle direction if clicking the same column rankingSortState.direction = rankingSortState.direction === 'asc' ? 'desc' : 'asc'; } else { // New column, start with ascending rankingSortState.column = column; rankingSortState.direction = 'asc'; } console.log('[Sort] New state:', rankingSortState.column, rankingSortState.direction); rankingPaginationState.currentPage = 1; renderRankingAiTab(); }); }); console.log('[Sort] Wired', freshHeaders.length, 'headers'); } function wireRankingPagination() { const firstBtn = document.getElementById("ranking-pagination-first"); const prevBtn = document.getElementById("ranking-pagination-prev"); const nextBtn = document.getElementById("ranking-pagination-next"); const lastBtn = document.getElementById("ranking-pagination-last"); const rowsPerPageSelect = document.getElementById("ranking-rows-per-page"); if (firstBtn) { firstBtn.addEventListener("click", () => { rankingPaginationState.currentPage = 1; renderRankingAiTab(); }); } if (prevBtn) { prevBtn.addEventListener("click", () => { if (rankingPaginationState.currentPage > 1) { rankingPaginationState.currentPage--; renderRankingAiTab(); } }); } if (nextBtn) { nextBtn.addEventListener("click", () => { rankingPaginationState.currentPage++; renderRankingAiTab(); }); } if (lastBtn) { lastBtn.addEventListener("click", () => { // Calculate total pages from current data const { combinedRows } = RankingAiModule.state(); if (!combinedRows || !Array.isArray(combinedRows)) return; const filteredRows = applyRankingFilters(combinedRows); const totalRows = filteredRows.length; const rowsPerPage = rankingPaginationState.rowsPerPage === 'all' ? totalRows : rankingPaginationState.rowsPerPage; const totalPages = rowsPerPage > 0 ? Math.ceil(totalRows / rowsPerPage) : 1; rankingPaginationState.currentPage = totalPages; renderRankingAiTab(); }); } if (rowsPerPageSelect) { rowsPerPageSelect.addEventListener("change", () => { const value = rowsPerPageSelect.value; rankingPaginationState.rowsPerPage = value === 'all' ? 'all' : parseInt(value, 10); rankingPaginationState.currentPage = 1; // Reset to first page renderRankingAiTab(); }); } } // ============================================ // AI Sources & Influence Tab Functions // ============================================ /** * Classify domain source type based on domain pattern * @param {string} domain - Domain name (e.g., "visualeducation.com") * @returns {string} Source type classification */ function classifyDomainSourceType(domain) { if (!domain) return 'Other'; const lower = domain.toLowerCase(); // Directory patterns if (lower.includes('yell.com') || lower.includes('tripadvisor') || lower.includes('facebook.com/pages') || lower.includes('thomsonlocal') || lower.includes('yell.co.uk') || lower.includes('freeindex')) { return 'Directory'; } // Review platform patterns if (lower.includes('trustpilot') || lower.includes('google.com/maps') || lower.includes('reviews.co.uk') || lower.includes('feefo')) { return 'Review platform'; } // Course marketplace / education patterns if (lower.includes('udemy') || lower.includes('coursera') || lower.includes('visualeducation') || lower.includes('skillshare') || lower.includes('edx') || lower.includes('futurelearn')) { return 'Course marketplace / education'; } // Publisher / blog patterns (common content domains) if (lower.includes('blog') || lower.includes('medium.com') || lower.includes('wordpress.com') || lower.includes('blogger.com')) { return 'Publisher / blog'; } return 'Other'; } /** * Aggregate AI citation data by domain * @param {Array} combinedRows - Array of keyword rows with AI citation data * @param {string} targetDomain - Target domain (e.g., "alanranger.com") * @returns {Array} Array of domain stats objects */ function aggregateAiDomainStats(combinedRows, targetDomain = 'alanranger.com') { if (!combinedRows || !Array.isArray(combinedRows)) { debugLog('⚠ aggregateAiDomainStats: No combinedRows provided', 'warn'); return []; } const domainMap = new Map(); const targetDomainLower = targetDomain.toLowerCase(); // Track total citations across all keywords let totalAllCitations = 0; combinedRows.forEach(row => { // Get AI citations for this keyword const aiCitations = row.ai_alan_citations || []; const competitorCounts = row.competitor_counts || {}; const aiTotalCitations = row.ai_total_citations || 0; totalAllCitations += aiTotalCitations; // Process our own citations if (aiCitations && aiCitations.length > 0) { aiCitations.forEach(citation => { if (!citation || !citation.url) return; try { const urlObj = new URL(citation.url); const domain = urlObj.hostname.toLowerCase(); if (!domainMap.has(domain)) { domainMap.set(domain, { domain, is_self: domain.includes(targetDomainLower), total_citations: 0, keyword_count: 0, keywords: new Set(), example_urls: [] }); } const stats = domainMap.get(domain); stats.total_citations += 1; if (!stats.keywords.has(row.keyword)) { stats.keywords.add(row.keyword); stats.keyword_count += 1; } if (stats.example_urls.length < 2) { stats.example_urls.push(citation.url); } } catch (e) { // Invalid URL, skip } }); } // Process competitor citations from competitor_counts (per keyword) Object.entries(competitorCounts).forEach(([domain, count]) => { if (!domain || !count) return; const domainLower = domain.toLowerCase(); if (!domainMap.has(domainLower)) { domainMap.set(domainLower, { domain: domainLower, is_self: domainLower.includes(targetDomainLower), total_citations: 0, keyword_count: 0, keywords: new Set(), example_urls: [] }); } const stats = domainMap.get(domainLower); stats.total_citations += count; if (!stats.keywords.has(row.keyword)) { stats.keywords.add(row.keyword); stats.keyword_count += 1; } }); }); // Convert to array and calculate share_of_citations const domainStats = Array.from(domainMap.values()).map(stats => { const share_of_citations = totalAllCitations > 0 ? (stats.total_citations / totalAllCitations) * 100 : 0; return { domain: stats.domain, is_self: stats.is_self, total_citations: stats.total_citations, keyword_count: stats.keyword_count, share_of_citations, source_type: classifyDomainSourceType(stats.domain), example_urls: stats.example_urls.slice(0, 2), keywords: Array.from(stats.keywords) }; }); // Sort by total_citations descending domainStats.sort((a, b) => b.total_citations - a.total_citations); debugLog(`✓ Aggregated ${domainStats.length} domains from AI citations`, 'info'); return domainStats; } /** * Render AI Sources & Influence tab */ function renderAiSourcesTab() { debugLog('📊 renderAiSourcesTab() called', 'info'); const { combinedRows } = RankingAiModule.state(); if (!combinedRows || combinedRows.length === 0) { debugLog('⚠ renderAiSourcesTab: No keyword data available', 'warn'); const tilesContainer = document.getElementById('ai-sources-tiles'); const tableBody = document.getElementById('ai-sources-table-body'); if (tilesContainer) tilesContainer.innerHTML = '

    No AI citation data available. Run a Ranking & AI check first.

    '; if (tableBody) tableBody.innerHTML = 'No data available. Run a Ranking & AI check first.'; return; } // Get target domain from property URL const propertyUrl = localStorage.getItem('gsc_property_url') || 'https://www.alanranger.com'; let targetDomain = 'alanranger.com'; try { const urlObj = new URL(propertyUrl); targetDomain = urlObj.hostname.replace('www.', ''); } catch (e) { debugLog(`⚠ Could not parse property URL: ${propertyUrl}, using default domain`, 'warn'); } // Aggregate domain stats const domainStats = aggregateAiDomainStats(combinedRows, targetDomain); // Store globally for filtering/sorting window.aiSourcesDomainStats = domainStats; // Render tiles renderAiSourcesTiles(domainStats, combinedRows.length); // Render source types breakdown renderAiSourcesTypesBreakdown(domainStats); // Render domain table renderAiSourcesTable(domainStats).catch(err => { console.error('Error rendering AI sources table:', err); }); } /** * Render summary tiles for AI Sources & Influence */ function renderAiSourcesTiles(domainStats, totalKeywords) { const tilesContainer = document.getElementById('ai-sources-tiles'); if (!tilesContainer) return; // Calculate metrics const selfDomain = domainStats.find(d => d.is_self); const selfCitations = selfDomain ? selfDomain.total_citations : 0; const totalCitations = domainStats.reduce((sum, d) => sum + d.total_citations, 0); const selfShare = totalCitations > 0 ? (selfCitations / totalCitations) * 100 : 0; const keywordsWithSelfCitations = selfDomain ? selfDomain.keyword_count : 0; const externalDomains = domainStats.filter(d => !d.is_self).length; const pctKeywordsWithCitations = totalKeywords > 0 ? (keywordsWithSelfCitations / totalKeywords) * 100 : 0; const ragForShare = () => { if (totalCitations <= 0) return 'neutral'; if (selfShare >= 20) return 'green'; if (selfShare >= 10) return 'amber'; return 'red'; }; const ragForKeywordCoverage = () => { if (totalKeywords <= 0) return 'neutral'; if (pctKeywordsWithCitations >= 60) return 'green'; if (pctKeywordsWithCitations >= 30) return 'amber'; return 'red'; }; const statusForRag = (rag) => { if (rag === 'green') return 'Strong'; if (rag === 'amber') return 'Moderate'; if (rag === 'red') return 'Weak'; return 'Info'; }; const makePill = ({ rag, value, label, status, footer }) => `
    ${value}
    ${label}
    ${status}
    `; const shareRag = ragForShare(); const coverageRag = ragForKeywordCoverage(); const html = ` ${makePill({ rag: shareRag, value: `${selfCitations}/${totalCitations}`, label: 'Your AI citations', status: statusForRag(shareRag), footer: 'Share of AI citations that reference your site across tracked keywords.' })} ${makePill({ rag: 'neutral', value: `${externalDomains}`, label: 'Top external domains', status: 'Info', footer: 'Domains most often cited alongside you in AI answers.' })} ${makePill({ rag: coverageRag, value: `${keywordsWithSelfCitations}/${totalKeywords}`, label: 'Keywords with your citations', status: statusForRag(coverageRag), footer: 'How often AI can already see your site as a source for your tracked queries.' })} ${makePill({ rag: shareRag, value: `${selfShare.toFixed(1)}%`, label: 'Your share of AI citations', status: statusForRag(shareRag), footer: 'Higher values mean AI relies more on your content instead of competitors or directories.' })} `; tilesContainer.innerHTML = html; } /** * Render source types breakdown */ function renderAiSourcesTypesBreakdown(domainStats) { const container = document.getElementById('ai-sources-types-breakdown'); if (!container) return; // Group by source type const typeGroups = {}; domainStats.forEach(stat => { const type = stat.source_type; if (!typeGroups[type]) { typeGroups[type] = { domains: 0, citations: 0 }; } typeGroups[type].domains += 1; typeGroups[type].citations += stat.total_citations; }); const totalCitations = domainStats.reduce((sum, d) => sum + d.total_citations, 0); let html = '
      '; Object.entries(typeGroups).forEach(([type, data]) => { const pct = totalCitations > 0 ? (data.citations / totalCitations) * 100 : 0; html += `
    • ${type}: ${data.domains} domains (${pct.toFixed(1)}% of citations)
    • `; }); html += '
    '; container.innerHTML = html; } /** * Render domain influence table */ async function renderAiSourcesTable(domainStats, filters = {}) { const tableBody = document.getElementById('ai-sources-table-body'); if (!tableBody) return; // Fetch domain metadata and domain strength for all domains (same as Ranking & AI module) const allDomains = domainStats.map(stat => stat.domain); const domainMetadata = await fetchDomainMetadataForDomains(allDomains); const normalizedDomains = allDomains.map(normalizeDomainForStrength).filter(Boolean); // Fetch domain strength for all domains (not just first 30) // Note: fetchLatestDomainStrengthForDomains has a 30 domain limit, so we need to batch if needed const domainStrength = await fetchLatestDomainStrengthForDomains(normalizedDomains); // If we have more than 30 domains, fetch in batches if (normalizedDomains.length > 30) { const batches = []; for (let i = 0; i < normalizedDomains.length; i += 30) { batches.push(normalizedDomains.slice(i, i + 30)); } for (const batch of batches.slice(1)) { const batchStrength = await fetchLatestDomainStrengthForDomains(batch); Object.assign(domainStrength, batchStrength); } } // Separate alanranger.com (self) from others // Self domain is always shown regardless of filters (sticky first row) const selfDomain = domainStats.find(stat => stat.is_self); const otherDomains = domainStats.filter(stat => !stat.is_self); // Apply filters to other domains (self domain always shown, filtered separately if needed) let filtered = otherDomains.filter(stat => { if (filters.type && filters.type !== 'all' && stat.source_type !== filters.type) { return false; } if (filters.domainType && filters.domainType !== 'all') { const normalizedDomain = normalizeDomainForStrength(stat.domain); const meta = domainMetadata[normalizedDomain] || { domain_type: 'unmapped' }; const domainType = meta.domain_type && meta.domain_type !== 'unmapped' ? meta.domain_type : 'unmapped'; if (filters.domainType === 'self' || filters.domainType === 'your_site') { // Filter is 'self' or 'your_site', but this is a competitor domain, so exclude it return false; } else if (filters.domainType === 'competitor') { // Filter is 'competitor', show all non-self domains (already filtered) // No additional filtering needed } else { // Filter by specific domain_type value (e.g., 'site', 'platform', 'directory', 'unmapped', etc.) if (domainType !== filters.domainType) { return false; } } } if (filters.domain && !stat.domain.toLowerCase().includes(filters.domain.toLowerCase())) { return false; } return true; }); // Apply sorting (default: competitor - competitors first) const sortColumn = window.aiSourcesSortState?.column || 'competitor'; const sortDirection = window.aiSourcesSortState?.direction || 'desc'; filtered.sort((a, b) => { let aVal, bVal; switch (sortColumn) { case 'domain': aVal = a.domain.toLowerCase(); bVal = b.domain.toLowerCase(); break; case 'domain_type': // Sort by domain_type string value from metadata (using mapped labels for consistent sorting) const aNorm = normalizeDomainForStrength(a.domain); const bNorm = normalizeDomainForStrength(b.domain); const aMeta = domainMetadata[aNorm] || { domain_type: 'unmapped' }; const bMeta = domainMetadata[bNorm] || { domain_type: 'unmapped' }; // For self domains, use "your_site" if unmapped, otherwise use domain_type if (a.is_self) { const aDomainType = aMeta.domain_type && aMeta.domain_type !== 'unmapped' ? aMeta.domain_type : 'your_site'; aVal = getDomainTypeLabel(aDomainType); } else { const aDomainType = aMeta.domain_type && aMeta.domain_type !== 'unmapped' ? aMeta.domain_type : 'zzz_unmapped'; aVal = getDomainTypeLabel(aDomainType) || 'zzz_unmapped'; } if (b.is_self) { const bDomainType = bMeta.domain_type && bMeta.domain_type !== 'unmapped' ? bMeta.domain_type : 'your_site'; bVal = getDomainTypeLabel(bDomainType); } else { const bDomainType = bMeta.domain_type && bMeta.domain_type !== 'unmapped' ? bMeta.domain_type : 'zzz_unmapped'; bVal = getDomainTypeLabel(bDomainType) || 'zzz_unmapped'; } break; case 'competitor': // Sort by competitor status (competitors first, then non-competitors) const aNormComp = normalizeDomainForStrength(a.domain); const bNormComp = normalizeDomainForStrength(b.domain); const aMetaComp = domainMetadata[aNormComp] || { is_competitor: false }; const bMetaComp = domainMetadata[bNormComp] || { is_competitor: false }; aVal = aMetaComp.is_competitor ? 1 : 0; bVal = bMetaComp.is_competitor ? 1 : 0; break; case 'rank': // Sort by domain rank score const aNormRank = normalizeDomainForStrength(a.domain); const bNormRank = normalizeDomainForStrength(b.domain); const aStrength = domainStrength[aNormRank] || { score: null }; const bStrength = domainStrength[bNormRank] || { score: null }; aVal = aStrength.score !== null ? aStrength.score : -1; bVal = bStrength.score !== null ? bStrength.score : -1; break; case 'type': aVal = a.source_type; bVal = b.source_type; break; case 'citations': aVal = a.total_citations; bVal = b.total_citations; break; case 'keywords': aVal = a.keyword_count; bVal = b.keyword_count; break; case 'share': aVal = a.share_of_citations; bVal = b.share_of_citations; break; default: return 0; } if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1; if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1; return 0; }); if (filtered.length === 0 && !selfDomain) { tableBody.innerHTML = 'No domains match the current filters.'; return; } tableBody.innerHTML = ''; // Always render self domain first with yellow highlight (regardless of filters, but respect domain search and domain type filter) if (selfDomain) { // Check if self domain should be shown based on filters const normalizedDomain = normalizeDomainForStrength(selfDomain.domain); const meta = domainMetadata[normalizedDomain] || { domain_type: 'unmapped', is_competitor: false }; const domainType = meta.domain_type && meta.domain_type !== 'unmapped' ? meta.domain_type : 'your_site'; let showSelf = true; if (filters.domain && !selfDomain.domain.toLowerCase().includes(filters.domain.toLowerCase())) { showSelf = false; } if (filters.domainType && filters.domainType !== 'all') { if (filters.domainType === 'self' || filters.domainType === 'your_site') { // Show self domain showSelf = showSelf && true; } else if (filters.domainType === 'competitor') { // Hide self domain when filtering for competitors showSelf = false; } else { // Filter by specific domain_type value showSelf = showSelf && (domainType === filters.domainType); } } if (showSelf) { const strength = domainStrength[normalizedDomain] || null; const selfRow = createDomainRow(selfDomain, true, meta, strength); tableBody.appendChild(selfRow); } } // Render filtered other domains filtered.forEach(stat => { const normalizedDomain = normalizeDomainForStrength(stat.domain); const meta = domainMetadata[normalizedDomain] || { domain_type: 'unmapped', is_competitor: false }; const strength = domainStrength[normalizedDomain] || null; const row = createDomainRow(stat, false, meta, strength); tableBody.appendChild(row); }); // Re-attach sort listeners after table is rendered if (typeof window.attachAiSourcesSortListeners === 'function') { setTimeout(() => { window.attachAiSourcesSortListeners(); }, 50); } } /** * Map domain_type value to display label (same as Ranking & AI module) */ function getDomainTypeLabel(domainType) { const domainTypeMap = { 'unmapped': 'Unmapped', 'your_site': 'Your site', 'platform': 'Platform', 'directory': 'Directory', 'publisher': 'Publisher', 'vendor': 'Vendor', 'institution': 'Institution', 'government': 'Government', 'site': 'Site' }; return domainTypeMap[domainType] || domainType || ''; } /** * Create a table row for a domain stat */ function createDomainRow(stat, isSelf, meta, strength) { const tr = document.createElement('tr'); tr.dataset.domain = stat.domain; // Make self domain row sticky and highlight in yellow (below header) if (isSelf) { tr.style.position = 'sticky'; tr.style.top = '40px'; // Below sticky header (approximate header height) tr.style.zIndex = '15'; tr.style.backgroundColor = '#ffffcc'; tr.style.fontWeight = '600'; } // Domain const tdDomain = document.createElement('td'); tdDomain.textContent = stat.domain; if (stat.is_self) { tdDomain.style.fontWeight = '700'; tdDomain.style.color = '#166534'; tdDomain.style.backgroundColor = '#ffffcc'; // Ensure background covers cell } tr.appendChild(tdDomain); // Domain type (use same categorization as Ranking & AI module) const tdDomainType = document.createElement('td'); tdDomainType.style.padding = '0.5rem 0.4rem'; tdDomainType.style.fontSize = '0.8rem'; tdDomainType.style.wordWrap = 'break-word'; tdDomainType.style.overflowWrap = 'break-word'; if (stat.is_self) { tdDomainType.style.backgroundColor = '#ffffcc'; // Ensure background covers cell } // Map domain_type value to display label (same as Ranking & AI) // Always show domain_type for all rows let displayType = ''; if (stat.is_self) { // For self domain, show mapped label or "Your site" if unmapped const domainType = meta.domain_type && meta.domain_type !== 'unmapped' ? meta.domain_type : 'your_site'; displayType = getDomainTypeLabel(domainType); } else { // For non-self domains, show mapped label if available, otherwise show "Unmapped" if (meta.domain_type && meta.domain_type !== 'unmapped') { displayType = getDomainTypeLabel(meta.domain_type); } else { // Show "Unmapped" so user knows domain exists but type not assigned yet displayType = 'Unmapped'; } } tdDomainType.textContent = displayType; tdDomainType.style.color = displayType ? '#475569' : '#94a3b8'; if (stat.is_self) { tdDomainType.style.backgroundColor = '#ffffcc'; // Ensure background covers cell } tr.appendChild(tdDomainType); // Competitor column (separate from domain type) const tdCompetitor = document.createElement('td'); tdCompetitor.style.padding = '0.5rem 0.4rem'; tdCompetitor.style.fontSize = '0.8rem'; tdCompetitor.style.textAlign = 'center'; if (stat.is_self) { tdCompetitor.style.backgroundColor = '#ffffcc'; // Ensure background covers cell } // Show competitor badge if is_competitor is true (same as Ranking & AI module) if (meta.is_competitor) { const badge = document.createElement('span'); badge.textContent = 'Competitor'; badge.setAttribute('data-competitor-badge', 'true'); badge.style.display = 'inline-block'; badge.style.padding = '0.125rem 0.5rem'; badge.style.fontSize = '0.65rem'; badge.style.fontWeight = '600'; badge.style.color = '#dc2626'; badge.style.backgroundColor = '#fee2e2'; badge.style.borderRadius = '4px'; badge.style.border = '1px solid #fecaca'; tdCompetitor.appendChild(badge); } else { tdCompetitor.textContent = '—'; tdCompetitor.style.color = '#94a3b8'; } tr.appendChild(tdCompetitor); // Domain Rank const tdRank = document.createElement('td'); tdRank.style.textAlign = 'right'; tdRank.style.padding = '0.5rem 0.4rem'; tdRank.style.fontSize = '0.8rem'; if (stat.is_self) { tdRank.style.backgroundColor = '#ffffcc'; // Ensure background covers cell } tdRank.innerHTML = renderDomainRankCellHtml(strength); tr.appendChild(tdRank); // Citations const tdCitations = document.createElement('td'); tdCitations.textContent = stat.total_citations; if (stat.is_self) { tdCitations.style.backgroundColor = '#ffffcc'; // Ensure background covers cell } tr.appendChild(tdCitations); // Keywords const tdKeywords = document.createElement('td'); tdKeywords.textContent = stat.keyword_count; if (stat.is_self) { tdKeywords.style.backgroundColor = '#ffffcc'; // Ensure background covers cell } tr.appendChild(tdKeywords); // Citation share const tdShare = document.createElement('td'); tdShare.textContent = `${stat.share_of_citations.toFixed(1)}%`; if (stat.is_self) { tdShare.style.backgroundColor = '#ffffcc'; // Ensure background covers cell } tr.appendChild(tdShare); // Example page const tdExample = document.createElement('td'); if (stat.is_self) { tdExample.style.backgroundColor = '#ffffcc'; // Ensure background covers cell } if (stat.example_urls && stat.example_urls.length > 0) { const a = document.createElement('a'); a.href = stat.example_urls[0]; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.textContent = stat.is_self ? new URL(stat.example_urls[0]).pathname : stat.example_urls[0]; a.style.color = '#0284c7'; a.style.textDecoration = 'none'; tdExample.appendChild(a); } else { tdExample.textContent = '—'; } tr.appendChild(tdExample); // Click handler tr.style.cursor = 'pointer'; tr.addEventListener('click', () => { document.querySelectorAll('#ai-sources-table-body tr').forEach(r => r.classList.remove('ranking-table-row--selected')); tr.classList.add('ranking-table-row--selected'); renderAiSourcesDetail(stat); }); return tr; } /** * Render domain detail panel */ function renderAiSourcesDetail(stat) { const panel = document.getElementById('ai-sources-detail-panel'); const domainEl = document.getElementById('ai-sources-detail-domain'); const metaEl = document.getElementById('ai-sources-detail-meta'); const contentEl = document.getElementById('ai-sources-detail-content'); if (!panel || !domainEl || !metaEl || !contentEl) return; panel.style.display = 'block'; domainEl.textContent = stat.domain; metaEl.textContent = `Cited in ${stat.total_citations} AI answer${stat.total_citations !== 1 ? 's' : ''} across ${stat.keyword_count} tracked keyword${stat.keyword_count !== 1 ? 's' : ''} (${stat.share_of_citations.toFixed(1)}% of all citations).`; let html = ''; // Why this domain matters html += '
    '; html += '
    Why this domain matters
    '; if (stat.is_self) { html += '

    This is your own site. AI is using your content in ' + stat.total_citations + ' answer' + (stat.total_citations !== 1 ? 's' : '') + ' across ' + stat.keyword_count + ' keyword' + (stat.keyword_count !== 1 ? 's' : '') + '. Increasing citations here usually comes from stronger schema, internal linking and coverage on key topics.

    '; } else if (stat.source_type === 'Directory') { html += '

    This is a directory / listing site. Repeated citations suggest AI trusts this listing as a key reference in your niche. Ensure your profile, NAP details and reviews are complete and consistent.

    '; } else if (stat.source_type === 'Review platform') { html += '

    This is a review platform. AI often surfaces businesses with strong, consistent reviews from here. Make sure your profile is claimed, reviews are encouraged, and descriptions match your positioning.

    '; } else if (stat.source_type === 'Course marketplace / education') { html += '

    This is an education or course marketplace. AI is seeing it as an alternative source for photography learning. Consider whether partnering, listing, or differentiating your on-site course pages against this platform makes sense.

    '; } else if (stat.source_type === 'Publisher / blog') { html += '

    This is a content publisher. Repeated citations suggest AI trusts their articles for informational queries. Collaborations, guest posts, or references from this domain can help strengthen your topical authority.

    '; } else { html += '

    This domain is frequently cited, but doesn\'t fall into a specific category. Review its content and decide if it\'s a directory, partner, competitor or something to monitor.

    '; } html += '
    '; // Keywords where this domain appears html += '
    '; html += '
    Keywords where this domain appears
    '; html += '
      '; const keywordsToShow = stat.keywords.slice(0, 10); keywordsToShow.forEach(keyword => { html += `
    • ${keyword}
    • `; }); if (stat.keywords.length > 10) { html += `
    • ... and ${stat.keywords.length - 10} more
    • `; } html += '
    '; // Suggested next steps html += '
    '; html += '
    Suggested next steps
    '; html += '
      '; if (stat.is_self) { const selfShare = stat.share_of_citations; if (selfShare < 30) { html += '
    • Strengthen schema and content coverage on high-demand keywords where you\'re not cited yet.
    • '; html += '
    • Revisit internal linking from money pages to these informational topics to raise their prominence.
    • '; } } else if (stat.source_type === 'Directory' || stat.source_type === 'Review platform') { html += '
    • Audit your listing/profile on this domain (NAP details, description, categories).
    • '; html += '
    • Encourage satisfied students/clients to leave reviews here to reinforce Authority.
    • '; } else if (stat.source_type === 'Course marketplace / education') { html += '
    • Review how your offerings compare to what\'s listed here. Decide whether to list on this platform or create on-site content addressing the same needs.
    • '; } else if (stat.source_type === 'Publisher / blog') { html += '
    • Identify relevant articles and consider outreach for mentions, interviews or guest content pointing back to your key pages.
    • '; } html += '
    '; contentEl.innerHTML = html; } // Edit Keywords functionality (function() { // Wait for DOM to be ready function initEditKeywords() { const modal = document.getElementById('edit-keywords-modal'); const openBtn = document.getElementById('edit-keywords-btn'); const closeBtn = document.getElementById('edit-keywords-close'); const cancelBtn = document.getElementById('edit-keywords-cancel'); const saveBtn = document.getElementById('edit-keywords-save'); const textarea = document.getElementById('edit-keywords-textarea'); const statusEl = document.getElementById('edit-keywords-status'); if (!modal || !openBtn) { // Elements not found yet, try again later setTimeout(initEditKeywords, 100); return; } // Ensure modal is hidden initially - use !important to override any other styles modal.style.setProperty('display', 'none', 'important'); // Force close on any click outside or escape - emergency close const forceClose = () => { modal.style.setProperty('display', 'none', 'important'); }; // Make forceClose available globally for emergency window.forceCloseEditKeywordsModal = forceClose; function showModal() { if (typeof debugLog === 'function') debugLog('[Edit Keywords] showModal() called', 'info'); if (modal) { if (typeof debugLog === 'function') debugLog('[Edit Keywords] Modal found, showing...', 'info'); modal.style.setProperty('display', 'flex', 'important'); if (typeof debugLog === 'function') debugLog('[Edit Keywords] Calling loadKeywords()...', 'info'); loadKeywords(); } else { if (typeof debugLog === 'function') debugLog('[Edit Keywords] Modal element not found!', 'error'); } } function hideModal() { if (modal) { modal.style.display = 'none'; modal.style.setProperty('display', 'none', 'important'); if (statusEl) statusEl.textContent = ''; if (textarea) { textarea.value = ''; textarea.disabled = false; } } } async function loadKeywords() { if (typeof debugLog === 'function') debugLog('[Edit Keywords] loadKeywords() called', 'info'); if (!textarea) { if (typeof debugLog === 'function') debugLog('[Edit Keywords] Textarea not found', 'error'); if (statusEl) { statusEl.textContent = 'ERROR: Textarea element not found'; statusEl.style.color = '#dc2626'; statusEl.style.fontWeight = 'bold'; } return; } if (typeof debugLog === 'function') debugLog('[Edit Keywords] Setting loading state...', 'info'); textarea.value = 'Loading keywords...'; textarea.disabled = true; textarea.style.color = '#1e293b'; textarea.style.backgroundColor = '#ffffff'; if (statusEl) { statusEl.textContent = 'Loading keywords from server...'; statusEl.style.color = '#64748b'; statusEl.style.fontWeight = 'normal'; } try { // First try to get from localStorage as fallback let keywordsFromStorage = []; try { const storedData = localStorage.getItem('rankingAiData'); if (storedData) { const parsed = JSON.parse(storedData); if (parsed.combinedRows && Array.isArray(parsed.combinedRows)) { keywordsFromStorage = [...new Set(parsed.combinedRows.map(r => r?.keyword).filter(Boolean))].sort(); if (typeof debugLog === 'function') debugLog(`[Edit Keywords] Found ${keywordsFromStorage.length} keywords in localStorage`, 'info'); } } } catch (e) { if (typeof debugLog === 'function') debugLog(`[Edit Keywords] Could not read from localStorage: ${e.message}`, 'warn'); } const apiEndpoint = apiUrl('/api/keywords/get'); if (typeof debugLog === 'function') debugLog(`[Edit Keywords] Fetching from: ${apiEndpoint}`, 'info'); const resp = await fetch(apiEndpoint); if (typeof debugLog === 'function') debugLog(`[Edit Keywords] Response status: ${resp.status} ${resp.statusText}`, 'info'); if (!resp.ok) { const errorText = await resp.text(); if (typeof debugLog === 'function') debugLog(`[Edit Keywords] API error response: ${errorText}`, 'error'); // Fallback to localStorage if API fails if (keywordsFromStorage.length > 0) { if (typeof debugLog === 'function') debugLog(`[Edit Keywords] Using localStorage fallback due to API error`, 'warn'); textarea.value = keywordsFromStorage.join('\n'); textarea.disabled = false; textarea.style.color = '#1e293b'; textarea.style.backgroundColor = '#ffffff'; if (statusEl) { statusEl.textContent = `⚠ Loaded ${keywordsFromStorage.length} keywords from cache (API error: ${resp.status})`; statusEl.style.color = '#f59e0b'; statusEl.style.fontWeight = '600'; } return; } throw new Error(`HTTP ${resp.status}: ${errorText.substring(0, 100)}`); } const data = await resp.json(); if (typeof debugLog === 'function') debugLog(`[Edit Keywords] Response: status=${data.status}, keywords count=${data.keywords?.length || 0}, reason=${data.meta?.reason || 'none'}`, 'info'); if (data.status === 'ok' && Array.isArray(data.keywords)) { if (typeof debugLog === 'function') debugLog(`[Edit Keywords] Found ${data.keywords.length} keywords from API`, 'info'); if (data.keywords.length > 0) { const keywordsText = data.keywords.join('\n'); if (typeof debugLog === 'function') debugLog(`[Edit Keywords] Setting textarea value, length: ${keywordsText.length}`, 'info'); textarea.value = keywordsText; textarea.disabled = false; textarea.style.color = '#1e293b'; textarea.style.backgroundColor = '#ffffff'; textarea.focus(); if (statusEl) { statusEl.textContent = `✓ Loaded ${data.keywords.length} keywords`; statusEl.style.color = '#10b981'; statusEl.style.fontWeight = '600'; } if (typeof debugLog === 'function') debugLog('[Edit Keywords] Keywords loaded successfully', 'success'); } else { if (typeof debugLog === 'function') debugLog('[Edit Keywords] No keywords found in API response', 'warn'); // Try localStorage fallback if (keywordsFromStorage.length > 0) { if (typeof debugLog === 'function') debugLog(`[Edit Keywords] Using localStorage fallback (${keywordsFromStorage.length} keywords)`, 'warn'); textarea.value = keywordsFromStorage.join('\n'); textarea.disabled = false; textarea.style.color = '#1e293b'; textarea.style.backgroundColor = '#ffffff'; if (statusEl) { statusEl.textContent = `⚠ Loaded ${keywordsFromStorage.length} keywords from cache (not found in latest audit)`; statusEl.style.color = '#f59e0b'; statusEl.style.fontWeight = '600'; } } else { textarea.value = ''; textarea.disabled = false; textarea.style.color = '#1e293b'; textarea.style.backgroundColor = '#ffffff'; if (statusEl) { statusEl.textContent = '⚠ No keywords found. You can add keywords below (one per line).'; statusEl.style.color = '#f59e0b'; statusEl.style.fontWeight = '600'; } } } } else { if (typeof debugLog === 'function') debugLog(`[Edit Keywords] Invalid response format. Status: ${data.status}, Keywords type: ${typeof data.keywords}, Is array: ${Array.isArray(data.keywords)}`, 'error'); textarea.value = ''; textarea.disabled = false; textarea.style.color = '#1e293b'; textarea.style.backgroundColor = '#ffffff'; if (statusEl) { statusEl.textContent = '❌ Invalid response format from server. Check debug log for details.'; statusEl.style.color = '#dc2626'; statusEl.style.fontWeight = 'bold'; } } } catch (err) { if (typeof debugLog === 'function') debugLog(`[Edit Keywords] Exception: ${err.message}`, 'error'); textarea.value = ''; textarea.disabled = false; textarea.style.color = '#1e293b'; textarea.style.backgroundColor = '#ffffff'; if (statusEl) { statusEl.textContent = '❌ Error loading keywords: ' + err.message + ' (Check debug log for details)'; statusEl.style.color = '#dc2626'; statusEl.style.fontWeight = 'bold'; statusEl.style.fontSize = '0.9rem'; } } } async function saveKeywords() { if (!textarea || !saveBtn) return; const keywordsText = textarea.value.trim(); const keywords = keywordsText.split('\n') .map(k => k.trim()) .filter(k => k.length > 0); if (keywords.length === 0) { if (statusEl) { statusEl.textContent = 'Please enter at least one keyword.'; statusEl.style.color = '#dc2626'; } return; } saveBtn.disabled = true; saveBtn.textContent = 'Saving...'; if (statusEl) { statusEl.textContent = 'Saving keywords...'; statusEl.style.color = '#64748b'; } try { const resp = await fetch(apiUrl('/api/keywords/save'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keywords }) }); // Check if response is JSON before parsing const contentType = resp.headers.get('content-type'); let data; if (contentType && contentType.includes('application/json')) { data = await resp.json(); } else { // Non-JSON response (likely an error page or plain text) const errorText = await resp.text(); throw new Error(`Server returned non-JSON response (${resp.status}): ${errorText.substring(0, 200)}`); } if (resp.ok && data.status === 'ok') { if (statusEl) { statusEl.textContent = `✓ Successfully saved ${data.count || keywords.length} keywords. Keywords will be updated on the next Ranking & AI check.`; statusEl.style.color = '#10b981'; } // Close modal after showing success message (don't trigger a new scan) setTimeout(() => { hideModal(); }, 2000); } else { throw new Error(data.message || data.details || 'Failed to save keywords'); } } catch (err) { console.error('[Edit Keywords] Save error:', err); if (statusEl) { statusEl.textContent = 'Error saving keywords: ' + err.message; statusEl.style.color = '#dc2626'; } saveBtn.disabled = false; saveBtn.textContent = 'Save Keywords'; } } // CSV upload handler const csvUpload = document.getElementById('csv-upload'); if (csvUpload) { csvUpload.addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; try { const text = await file.text(); // Parse CSV and populate textarea const lines = text.split('\n'); const keywords = []; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const firstColumn = trimmed.includes(',') ? trimmed.split(',')[0].trim() : trimmed; if (firstColumn) keywords.push(firstColumn); } if (keywords.length === 0) { if (statusEl) { statusEl.textContent = 'No keywords found in CSV file'; statusEl.style.color = '#dc2626'; } return; } textarea.value = keywords.join('\n'); if (statusEl) { statusEl.textContent = `✓ Loaded ${keywords.length} keywords from CSV`; statusEl.style.color = '#10b981'; } // Reset file input e.target.value = ''; } catch (err) { console.error('[Edit Keywords] CSV upload error:', err); if (statusEl) { statusEl.textContent = 'Error reading CSV file: ' + err.message; statusEl.style.color = '#dc2626'; } } }); } // Only attach event listeners - modal should NOT open automatically openBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); showModal(); }); // Close buttons - make sure they work if (closeBtn) { closeBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); hideModal(); }); } if (cancelBtn) { cancelBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); hideModal(); }); } if (saveBtn) { saveBtn.addEventListener('click', saveKeywords); } // Close modal when clicking outside modal.addEventListener('click', (e) => { if (e.target === modal) { e.preventDefault(); e.stopPropagation(); hideModal(); } }); // Close modal with Escape key - make it work globally const escapeHandler = (e) => { if (e.key === 'Escape' && modal) { const currentDisplay = window.getComputedStyle(modal).display; if (currentDisplay === 'flex' || currentDisplay === 'block') { e.preventDefault(); e.stopPropagation(); hideModal(); } } }; document.addEventListener('keydown', escapeHandler); // Also add a global function to force close window.closeEditKeywordsModal = hideModal; } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initEditKeywords); } else { initEditKeywords(); } })(); // Initialize AI Sources sort state (default: sort by competitor - competitors first) if (!window.aiSourcesSortState) { window.aiSourcesSortState = { column: 'competitor', direction: 'desc' }; } // Wire up AI Sources tab filters and sorting function wireAiSourcesFilters() { const typeFilter = document.getElementById('ai-sources-filter-type'); const domainTypeFilter = document.getElementById('ai-sources-filter-domain-type'); const domainFilter = document.getElementById('ai-sources-filter-domain'); const clearBtn = document.getElementById('ai-sources-filter-clear'); const updateFilters = async () => { const filters = { type: typeFilter ? typeFilter.value : 'all', domainType: domainTypeFilter ? domainTypeFilter.value : 'all', domain: domainFilter ? domainFilter.value.trim() : '' }; if (window.aiSourcesDomainStats) { await renderAiSourcesTable(window.aiSourcesDomainStats, filters).catch(err => { console.error('Error rendering AI sources table:', err); }); } }; if (typeFilter) typeFilter.addEventListener('change', updateFilters); if (domainTypeFilter) domainTypeFilter.addEventListener('change', updateFilters); if (domainFilter) domainFilter.addEventListener('input', updateFilters); if (clearBtn) { clearBtn.addEventListener('click', () => { if (typeFilter) typeFilter.value = 'all'; if (domainTypeFilter) domainTypeFilter.value = 'all'; if (domainFilter) domainFilter.value = ''; updateFilters(); }); } // Wire up table sorting - attach listeners after table is rendered window.attachAiSourcesSortListeners = function attachSortListeners() { const table = document.getElementById('ai-sources-table'); if (!table) return; // Remove any existing listeners by removing and re-adding the event listener // Use a single delegated listener on the table const handleSortClick = (e) => { const th = e.target.closest('th.sortable'); if (!th) return; e.preventDefault(); e.stopPropagation(); const column = th.dataset.sort; if (!column) return; if (window.aiSourcesSortState.column === column) { window.aiSourcesSortState.direction = window.aiSourcesSortState.direction === 'asc' ? 'desc' : 'asc'; } else { window.aiSourcesSortState.column = column; window.aiSourcesSortState.direction = 'desc'; } // Update sort indicators const allHeaders = table.querySelectorAll('th.sortable'); allHeaders.forEach(h => { h.classList.remove('sort-asc', 'sort-desc'); if (h.dataset.sort === window.aiSourcesSortState.column) { h.classList.add(`sort-${window.aiSourcesSortState.direction}`); } }); // Re-render table with new sort const filters = { type: typeFilter ? typeFilter.value : 'all', domainType: domainTypeFilter ? domainTypeFilter.value : 'all', domain: domainFilter ? domainFilter.value.trim() : '' }; if (window.aiSourcesDomainStats) { renderAiSourcesTable(window.aiSourcesDomainStats, filters).then(() => { // Re-attach listeners after re-render if (typeof window.attachAiSourcesSortListeners === 'function') { window.attachAiSourcesSortListeners(); } }).catch(err => { console.error('Error rendering AI sources table:', err); }); } }; // Remove old listener if it exists if (table._sortHandler) { table.removeEventListener('click', table._sortHandler); } table._sortHandler = handleSortClick; table.addEventListener('click', handleSortClick, true); // Use capture phase to catch events early // Set initial sort indicator on headers and add cursor style const sortableHeaders = table.querySelectorAll('th.sortable'); sortableHeaders.forEach(h => { h.style.cursor = 'pointer'; h.classList.remove('sort-asc', 'sort-desc'); if (h.dataset.sort === window.aiSourcesSortState.column) { h.classList.add(`sort-${window.aiSourcesSortState.direction}`); } }); } // Attach sort listeners initially window.attachAiSourcesSortListeners(); } // Make functions globally available window.renderAiSourcesTab = renderAiSourcesTab; window.wireAiSourcesFilters = wireAiSourcesFilters; // ========================= // Dashboard (Global Summary) // ========================= const DASHBOARD_RUNS_KEY = 'dashboard_global_runs_v1'; function dashboardSafeJsonParse(text, fallback = null) { try { if (!text) return fallback; return JSON.parse(text); } catch { return fallback; } } function formatUtcTimestamp(iso) { if (!iso) return '—'; const d = new Date(iso); if (Number.isNaN(d.getTime())) return '—'; const day = String(d.getUTCDate()).padStart(2, '0'); const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const month = monthNames[d.getUTCMonth()]; const year = d.getUTCFullYear(); const hh = String(d.getUTCHours()).padStart(2, '0'); const mm = String(d.getUTCMinutes()).padStart(2, '0'); const ss = String(d.getUTCSeconds()).padStart(2, '0'); return `${day} ${month} ${year}, ${hh}:${mm}:${ss} GMT`; } function scoreToRag(score, { greenAt = 70, amberAt = 50 } = {}) { const s = typeof score === 'number' ? score : null; if (s === null) return 'neutral'; if (s >= greenAt) return 'green'; if (s >= amberAt) return 'amber'; return 'red'; } function ragToAccent(rag) { if (rag === 'green') return '#10b981'; if (rag === 'amber') return '#f59e0b'; if (rag === 'red') return '#ef4444'; return 'rgba(148, 163, 184, 0.6)'; } function getDashboardRuns() { const runs = dashboardSafeJsonParse(localStorage.getItem(DASHBOARD_RUNS_KEY), []); return Array.isArray(runs) ? runs : []; } function saveDashboardRun(run) { const existing = getDashboardRuns(); const next = [...existing, run].slice(-50); localStorage.setItem(DASHBOARD_RUNS_KEY, JSON.stringify(next)); } function getRankingAiRowsFromStorage() { const local = dashboardSafeJsonParse(localStorage.getItem('rankingAiData'), null); if (local && Array.isArray(local.combinedRows)) return local.combinedRows; if (Array.isArray(window.rankingAiData)) return window.rankingAiData; return []; } function computeMoneyCitationsShareFromRankingRows(rows) { const fn = typeof window.computeAiCitationsByCitedUrlSegment === 'function' ? window.computeAiCitationsByCitedUrlSegment : null; if (!fn) return { money: null, total: null, pct: null }; const counts = fn(rows || []); const money = (counts.money || 0); const total = (counts.site || 0); const pct = total > 0 ? Math.round((money / total) * 100) : 0; return { money, total, pct }; } function computeMoneyCitationsSplitFromRankingRows(rows) { const fn = typeof window.computeAiCitationsByCitedUrlSegment === 'function' ? window.computeAiCitationsByCitedUrlSegment : null; if (!fn) return null; const counts = fn(rows || []); const moneyTotal = (counts.money || 0); const landing = (counts.landing || 0); const event = (counts.event || 0); const product = (counts.product || 0); const denom = moneyTotal > 0 ? moneyTotal : 0; return { moneyTotal, landing, event, product, landingPct: denom > 0 ? Math.round((landing / denom) * 100) : 0, eventPct: denom > 0 ? Math.round((event / denom) * 100) : 0, productPct: denom > 0 ? Math.round((product / denom) * 100) : 0 }; } function toNum(v) { const n = typeof v === 'number' ? v : (v == null ? NaN : Number(v)); return Number.isFinite(n) ? n : null; } function safeDiv(a, b) { const na = toNum(a); const nb = toNum(b); if (na == null || nb == null || nb === 0) return null; return na / nb; } function formatInt(v) { const n = toNum(v); if (n == null) return '—'; return Math.round(n).toLocaleString(); } function formatFixed(v, decimals = 1) { const n = toNum(v); if (n == null) return '—'; return n.toFixed(decimals); } function formatPct(v, decimals = 1) { const n = toNum(v); if (n == null) return '—'; return `${n.toFixed(decimals)}%`; } function deltaDir(curr, prev, { betterLower = false } = {}) { const c = toNum(curr); const p = toNum(prev); if (c == null || p == null) return 'flat'; if (c === p) return 'flat'; // If lower is better (e.g., avg position), then a decrease is "up" (good). if (betterLower) return (c < p) ? 'up' : 'down'; return (c > p) ? 'up' : 'down'; } function formatDeltaNumber(curr, prev, { decimals = 0, betterLower = false, pp = false } = {}) { const c = toNum(curr); const p = toNum(prev); if (c == null || p == null) return '—'; const raw = betterLower ? (p - c) : (c - p); const d = Number.isFinite(raw) ? raw : 0; const sign = d > 0 ? '+' : ''; const base = decimals > 0 ? d.toFixed(decimals) : String(Math.round(d)); return pp ? `${sign}${base}pp` : `${sign}${base}`; } function computePillarScoresFromAudit(audit) { const scores = audit?.scores || null; if (!scores) return null; return { authority: toNum(scores.authority?.score ?? scores.authority) ?? null, contentSchema: toNum(scores.contentSchema) ?? null, visibility: toNum(scores.visibility) ?? null, localEntity: toNum(scores.localEntity) ?? null, serviceArea: toNum(scores.serviceArea) ?? null, }; } function computeAuditKpisFromAudit(audit) { const sd = audit?.searchData || null; if (!sd) return null; return { clicks: toNum(sd.totalClicks) ?? 0, impressions: toNum(sd.totalImpressions) ?? 0, avgPosition: toNum(sd.averagePosition), ctrPct: toNum(sd.ctr), }; } function getRankFromRow(row) { const candidates = [ // Ranking & AI module uses these fields row?.best_rank_group, row?.best_rank_absolute, row?.current_rank, // Common alternatives row?.rank, row?.rank_position, row?.rankPosition, row?.position, row?.avgPosition, row?.gsc_avg_position, ]; for (const v of candidates) { const n = toNum(v); if (n != null && n > 0) return n; } return null; } function computeRankingKpisFromRows(rows) { const list = Array.isArray(rows) ? rows : []; let ranked = 0; let top3 = 0; let top10 = 0; let citationsTotal = 0; let aiOverviews = 0; for (const r of list) { const rank = getRankFromRow(r); if (rank != null) { ranked += 1; if (rank <= 3) top3 += 1; if (rank <= 10) top10 += 1; } const cit = toNum(r?.ai_alan_citations_count ?? r?.aiAlanCitationsCount); if (cit != null) citationsTotal += cit; const hasAio = (r?.ai_overview_present_any ?? r?.aiOverviewPresentAny ?? r?.has_ai_overview ?? r?.hasAiOverview); if (hasAio === true) aiOverviews += 1; } const top3SharePct = ranked > 0 ? Math.round((top3 / ranked) * 100) : null; const top10SharePct = ranked > 0 ? Math.round((top10 / ranked) * 100) : null; return { ranked, top3SharePct, top10SharePct, citationsTotal, aiOverviews }; } function computeMoneyPagesAggregateFromMetrics(moneyMetrics) { const rows = moneyMetrics?.rows; if (!Array.isArray(rows) || rows.length === 0) return null; let clicks = 0; let impressions = 0; let weightedPosImps = 0; for (const r of rows) { const c = toNum(r?.clicks) ?? 0; const i = toNum(r?.impressions) ?? 0; const p = toNum(r?.avgPosition ?? r?.position); clicks += c; impressions += i; if (p != null && i > 0) weightedPosImps += (p * i); } const ctrPct = impressions > 0 ? (clicks / impressions) * 100 : null; const avgPosition = (impressions > 0 && weightedPosImps > 0) ? (weightedPosImps / impressions) : null; // CTR targets (provide both so you can choose later) const targetCtrAbsPct = 2.5; // absolute const targetCtrPlusPp = (ctrPct != null) ? (ctrPct + 0.3) : null; // +0.3pp const extraClicksAbs = (ctrPct != null && impressions > 0) ? Math.max(0, Math.round((impressions * (targetCtrAbsPct / 100)) - clicks)) : null; const extraClicksPlus = (targetCtrPlusPp != null && impressions > 0) ? Math.max(0, Math.round((impressions * (targetCtrPlusPp / 100)) - clicks)) : null; // Position targets (also provide two) const targetPosAbs = 10; const targetPosMinus2 = (avgPosition != null) ? Math.max(1, avgPosition - 2) : null; return { clicks, impressions, ctrPct, avgPosition, targets: { ctrAbsPct: targetCtrAbsPct, ctrPlusPp: targetCtrPlusPp, posAbs: targetPosAbs, posMinus2: targetPosMinus2 }, uplift: { extraClicksAbs, extraClicksPlus } }; } function isOptimisationDataLoadedForDashboard() { const state = window.optimisationModuleState; // optimisationModuleState exists even before tasks are actually loaded, so avoid treating // an empty default state as "loaded" (otherwise dashboard shows misleading 0s / 100%). const hasKey = (typeof window.hasAdminKey === 'function') ? window.hasAdminKey() : false; const hasState = !!state && Array.isArray(state.allTasks); const hasTasks = hasState && state.allTasks.length > 0; // Consider loaded if we have tasks OR if dashboard tiles/impact/timeseries are available // (dashboard tiles might not always be available, but tasks are the important part) const loadedViaApi = !!state && ( hasTasks || state.dashboardTiles !== null || state.dashboardImpact !== null || state.dashboardTimeseries !== null ); return !!hasState && !!hasKey && !state.authError && loadedViaApi; } function getOptimisationTasksForDashboard() { const state = window.optimisationModuleState; if (!state || !Array.isArray(state.allTasks)) return []; let tasks = state.allTasks; const includeTestCheckbox = document.getElementById('optimisation-filter-include-test'); if (!includeTestCheckbox || !includeTestCheckbox.checked) { tasks = tasks.filter(t => !t.is_test_task); } return tasks; } function computeExecutionFromTasks(tasks, isLoaded) { if (!isLoaded) { return { active: null, updated30d: null, pct: null }; } const activeStatuses = ['planned', 'in_progress', 'monitoring']; const activeTasks = (tasks || []).filter(t => activeStatuses.includes(t.status)); const scope = window.optimisationModuleState?.scope || 'active_cycle'; const getLatest = typeof window.getLatestMeasurementInScope === 'function' ? window.getLatestMeasurementInScope : null; if (!getLatest) { return { active: activeTasks.length, updated30d: null, pct: null }; } const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); let updated = 0; for (const task of activeTasks) { const latest = getLatest(task, scope); if (!latest || !latest.captured_at) continue; const d = new Date(latest.captured_at); if (!Number.isNaN(d.getTime()) && d >= thirtyDaysAgo) updated += 1; } // If there are no active tasks, don't pretend it's "100% updated" — show neutral. if (activeTasks.length === 0) { return { active: 0, updated30d: 0, pct: null }; } const pct = Math.round((updated / activeTasks.length) * 100); return { active: activeTasks.length, updated30d: updated, pct }; } function computeEeatConfidence({ hasBacklinks, hasDomainStrength, hasAiCitations, hasLocalSignals }) { const signals = [hasBacklinks, hasDomainStrength, hasAiCitations, hasLocalSignals].filter(Boolean).length; if (signals >= 3) return 'High'; if (signals >= 2) return 'Medium'; return 'Low'; } function computeEeatScore({ audit, pillars, rankingKpis, domainStrength }) { const ac = audit?.scores?.authorityComponents || audit?.authorityComponents || null; const behaviour = toNum(ac?.behaviour); const reviews = toNum(ac?.reviews); // Backlinks: only treat as "measured" if backlinkMetrics exist (otherwise computeBacklinkScore returns 0) const hasBacklinks = !!audit?.backlinkMetrics; const backlinksRaw = toNum(ac?.backlinks); const backlinks = hasBacklinks ? backlinksRaw : 50; // Local signals / NAP const localSignals = audit?.localSignals || audit?.localSignalsSnapshot || null; const localData = localSignals ? ((localSignals.status && localSignals.status === 'ok' && localSignals.data) ? localSignals.data : (localSignals.data || localSignals)) : null; const nap = toNum(localData?.napConsistencyScore); const hasLocalSignals = nap != null || localData?.knowledgePanelDetected === true || (Array.isArray(localData?.locations) && localData.locations.length > 0); // Core pillar scores const localEntity = toNum(pillars?.localEntity); const contentSchema = toNum(pillars?.contentSchema); const snippet = toNum((audit?.snippetReadiness && typeof audit.snippetReadiness === 'object') ? audit.snippetReadiness.overallScore : audit?.snippetReadiness); // Domain Strength (0–100) const domainScoreRaw = toNum(domainStrength?.selfScore); const hasDomainStrength = typeof domainScoreRaw === 'number'; const domainScore = hasDomainStrength ? clampScore(domainScoreRaw) : 50; // AI citations signal (cap 0–100) const citationsTotal = toNum(rankingKpis?.citationsTotal); const hasAiCitations = typeof citationsTotal === 'number'; const citationsSignal = hasAiCitations ? Math.max(0, Math.min(100, citationsTotal)) : 50; // Sub-scores (EEAT v1) const safe50 = (v) => (typeof v === 'number' ? v : 50); const trustNap = (typeof nap === 'number') ? clampScore(nap) : safe50(localEntity); const experience = clampScore(0.5 * safe50(reviews) + 0.3 * safe50(behaviour) + 0.2 * safe50(localEntity)); const expertise = clampScore(0.6 * safe50(contentSchema) + 0.4 * safe50(snippet)); const authoritativeness = clampScore(0.5 * safe50(backlinks) + 0.3 * safe50(domainScore) + 0.2 * safe50(citationsSignal)); const trustworthiness = clampScore(0.6 * trustNap + 0.4 * safe50(reviews)); const eeat = clampScore( 0.2 * experience + 0.25 * expertise + 0.3 * authoritativeness + 0.25 * trustworthiness ); const confidence = computeEeatConfidence({ hasBacklinks, hasDomainStrength, hasAiCitations, hasLocalSignals }); return { score: Math.round(eeat), confidence, subscores: { experience: Math.round(experience), expertise: Math.round(expertise), authoritativeness: Math.round(authoritativeness), trustworthiness: Math.round(trustworthiness) } }; } function computeDashboardSnapshot() { const audit = dashboardSafeJsonParse(localStorage.getItem('last_audit_results'), null); const hasAudit = audit && audit.scores; let gaioScore = null; let aiSummaryScore = null; if (hasAudit && typeof window.calculateAiGeoScore === 'function') { const health = window.calculateAiGeoScore(audit.scores, audit.schemaAudit || null, audit.snippetReadiness || null); gaioScore = typeof health.aiGeoScore === 'number' ? health.aiGeoScore : null; aiSummaryScore = typeof health.aiSummary?.score === 'number' ? Math.round(health.aiSummary.score) : null; } const rankingRows = getRankingAiRowsFromStorage(); const moneyShare = computeMoneyCitationsShareFromRankingRows(rankingRows); const moneyShareSplit = computeMoneyCitationsSplitFromRankingRows(rankingRows); const rankingKpis = computeRankingKpisFromRows(rankingRows); const pillars = computePillarScoresFromAudit(audit); const auditKpis = computeAuditKpisFromAudit(audit); const moneyMetricsFromAudit = audit?.scores?.moneyPagesMetrics || audit?.moneyPagesMetrics || null; const moneyAgg = computeMoneyPagesAggregateFromMetrics(moneyMetricsFromAudit); const tasks = getOptimisationTasksForDashboard(); const exec = computeExecutionFromTasks(tasks, isOptimisationDataLoadedForDashboard()); const optLoaded = isOptimisationDataLoadedForDashboard(); const estimateFn = (typeof window.computeEstimatedExtraClicks28d === 'function') ? window.computeEstimatedExtraClicks28d : null; let optimisationPotentialClicks = null; if (optLoaded && estimateFn) { const activeStatuses = ['planned', 'in_progress', 'monitoring']; let sum = 0; for (const t of tasks) { if (!activeStatuses.includes(t.status)) continue; const v = toNum(estimateFn(t)); if (v != null) sum += v; } optimisationPotentialClicks = Math.round(sum); } const domainCache = dashboardSafeJsonParse(localStorage.getItem('dashboard_domain_strength_cache_v4'), null) || dashboardSafeJsonParse(localStorage.getItem('dashboard_domain_strength_cache_v3'), null) || dashboardSafeJsonParse(localStorage.getItem('dashboard_domain_strength_cache_v2'), null) || dashboardSafeJsonParse(localStorage.getItem('dashboard_domain_strength_cache_v1'), null); const eeat = computeEeatScore({ audit, pillars, rankingKpis, domainStrength: domainCache }); return { gaioScore, aiSummaryScore, eeatScore: eeat?.score ?? null, eeatConfidence: eeat?.confidence ?? null, eeatSubscores: eeat?.subscores ?? null, pillars, auditKpis, rankingKpis, moneyCitations: moneyShare.money, totalCitations: moneyShare.total, moneySharePct: moneyShare.pct, moneyShareSplit, moneyAgg, execActive: exec.active, execUpdated30d: exec.updated30d, execPct: exec.pct, optimisationPotentialClicks, domainStrength: domainCache }; } function setDial(el, { valueText, pct, rag, deltaText }) { if (!el) return; const safePct = Math.max(0, Math.min(100, typeof pct === 'number' ? pct : 0)); el.style.setProperty('--pct', String(safePct)); el.style.setProperty('--accent', ragToAccent(rag)); const valueEl = el.querySelector('[data-field="value"]'); const deltaEl = el.querySelector('[data-field="delta"]'); if (valueEl) valueEl.textContent = valueText || '—'; if (deltaEl) deltaEl.textContent = deltaText || '—'; } function updateDeltaIndicator(elId, curr, prev, { betterLower = false, decimals = 0, pp = false } = {}) { const el = document.getElementById(elId); if (!el) return; // If no current value, hide indicator if (typeof curr !== 'number') { el.style.display = 'none'; return; } // If no previous value, show "N/A" or "—" if (typeof prev !== 'number') { el.setAttribute('data-dir', 'flat'); el.innerHTML = ` N/A`; el.style.display = 'inline-flex'; return; } const delta = curr - prev; // If delta is 0, show "0" with flat styling if (delta === 0) { el.setAttribute('data-dir', 'flat'); el.innerHTML = ` 0`; el.style.display = 'inline-flex'; return; } const isBetter = betterLower ? delta < 0 : delta > 0; const dir = isBetter ? 'up' : 'down'; const arrow = isBetter ? '↑' : '↓'; let deltaText = ''; if (pp) { deltaText = `${delta > 0 ? '+' : ''}${delta.toFixed(decimals)}pp`; } else { deltaText = `${delta > 0 ? '+' : ''}${delta.toFixed(decimals)}`; } el.setAttribute('data-dir', dir); el.innerHTML = `${arrow} ${deltaText}`; el.style.display = 'inline-flex'; } function formatDelta(curr, prev) { if (typeof curr !== 'number' || typeof prev !== 'number') return '—'; const d = curr - prev; if (d === 0) return '0'; const sign = d > 0 ? '+' : ''; return `${sign}${Math.round(d)}`; } function computeFreshnessBadge(timestampIso, { warnDays = 3, staleDays = 7 } = {}) { if (!timestampIso) { return { rag: 'neutral', label: 'Not run', daysOld: null }; } const d = new Date(timestampIso); if (Number.isNaN(d.getTime())) { return { rag: 'neutral', label: 'Not run', daysOld: null }; } const ms = Date.now() - d.getTime(); const daysOld = Math.floor(ms / (1000 * 60 * 60 * 24)); if (daysOld >= staleDays) return { rag: 'red', label: `Stale (${daysOld}d)`, daysOld }; if (daysOld >= warnDays) return { rag: 'amber', label: `Stale (${daysOld}d)`, daysOld }; return { rag: 'green', label: 'Fresh', daysOld }; } function renderDashboardSummaryCards({ audit, ranking, moneyPages, optimisation, domainStrength }) { const grid = document.getElementById('dashboard-summary-grid'); if (!grid) return; const jumpBtn = (label, panelId) => ` `; const runBtn = (label, onClick, title = '') => ` `; const ragPill = (rag, label) => ` ${label} `; const tile = (label, value, deltaText, dir = 'flat', title = '') => { // Create indicator badge for top-right corner let indicatorHtml = ''; // Always show indicator - show delta if available, otherwise N/A if (deltaText && deltaText !== '—' && deltaText !== '' && deltaText !== '0') { const arrow = dir === 'up' ? '↑' : dir === 'down' ? '↓' : '—'; indicatorHtml = `
    ${arrow} ${deltaText}
    `; } else if (deltaText === '0') { // Show 0 with flat styling indicatorHtml = `
    0
    `; } else { // Show N/A when no delta data available indicatorHtml = `
    N/A
    `; } return `
    ${indicatorHtml}
    ${label}
    ${value}
    ${deltaText ? `
    ${deltaText}
    ` : ''}
    `; }; const trafficTile = (label, counts, title = '') => { const c = counts || {}; const w = (typeof c.worse === 'number') ? c.worse : '—'; const s = (typeof c.same === 'number') ? c.same : '—'; const b = (typeof c.better === 'number') ? c.better : '—'; return `
    ${label}
    ${w} Worse ${s} Same ${b} Better
    `; }; grid.innerHTML = `
    📊 Audit Scan
    ${audit?.timestamp ? `Last audit: ${formatUtcTimestamp(audit.timestamp)}` : 'No audit data yet'}
    ${ragPill(audit?.rag || 'neutral', audit?.ragLabel || 'Unknown')} ${jumpBtn('Open', 'overview')}
    ${tile('Clicks (28d)', audit?.kpis?.clicks ?? '—', audit?.deltas?.clicks ?? '', audit?.dirs?.clicks ?? 'flat', 'Total Google Search Console clicks over the last 28 days.')} ${tile('Impressions (28d)', audit?.kpis?.impressions ?? '—', audit?.deltas?.impressions ?? '', audit?.dirs?.impressions ?? 'flat', 'Total Google Search Console impressions over the last 28 days.')} ${tile('Avg position', audit?.kpis?.avgPosition ?? '—', audit?.deltas?.avgPosition ?? '', audit?.dirs?.avgPosition ?? 'flat', 'Average Search Console position (lower is better).')} ${tile('CTR', audit?.kpis?.ctr ?? '—', audit?.deltas?.ctr ?? '', audit?.dirs?.ctr ?? 'flat', 'Clicks ÷ impressions (last 28 days).')}
    🔍 Keyword Ranking and AI
    ${ranking?.timestamp ? `Last scan: ${formatUtcTimestamp(ranking.timestamp)}` : 'No Keyword Ranking and AI data yet'}
    ${ragPill(ranking?.rag || 'neutral', ranking?.ragLabel || 'Unknown')} ${ranking?.run ? runBtn(ranking.run.label || 'Run scan', ranking.run.onClick || '', ranking.run.title || '') : ''} ${jumpBtn('Open', 'ranking')}
    ${tile('Top 3 share', ranking?.kpis?.top3Share ?? '—', ranking?.deltas?.top3Share ?? '', ranking?.dirs?.top3Share ?? 'flat', 'Share of ranked tracked keywords currently in positions 1–3.')} ${tile('Top 10 share', ranking?.kpis?.top10Share ?? '—', ranking?.deltas?.top10Share ?? '', ranking?.dirs?.top10Share ?? 'flat', 'Share of ranked tracked keywords currently in positions 1–10.')} ${tile('AI citations', ranking?.kpis?.citations ?? '—', ranking?.deltas?.citations ?? '', ranking?.dirs?.citations ?? 'flat', 'Total AI citations found across tracked keywords in the latest scan.')} ${tile('Money share', ranking?.kpis?.moneyShare ?? '—', ranking?.deltas?.moneyShare ?? '', ranking?.dirs?.moneyShare ?? 'flat', 'Percent of AI citations that land on Money Pages.')}
    💰 URL Money Pages
    ${moneyPages?.note || 'Uses latest URL Money Pages metrics from the latest audit'}
    ${ragPill(moneyPages?.rag || 'neutral', moneyPages?.ragLabel || 'Unknown')} ${moneyPages?.run ? runBtn(moneyPages.run.label || 'Run scan', moneyPages.run.onClick || '', moneyPages.run.title || '') : ''} ${jumpBtn('Open', 'money')}
    ${tile('Money clicks (28d)', moneyPages?.kpis?.clicks ?? '—', moneyPages?.deltas?.clicks ?? '', moneyPages?.dirs?.clicks ?? 'flat', 'Total clicks to Money Pages over the last 28 days.')} ${tile('Money CTR', moneyPages?.kpis?.ctr ?? '—', moneyPages?.deltas?.ctr ?? '', moneyPages?.dirs?.ctr ?? 'flat', 'CTR across Money Pages (last 28 days).')} ${tile('Money avg pos', moneyPages?.kpis?.avgPosition ?? '—', moneyPages?.deltas?.avgPosition ?? '', moneyPages?.dirs?.avgPosition ?? 'flat', 'Average position across Money Pages (lower is better).')} ${tile('Uplift remaining', moneyPages?.kpis?.uplift ?? '—', moneyPages?.deltas?.uplift ?? '', moneyPages?.dirs?.uplift ?? 'flat', 'Estimated extra clicks available if Money Pages CTR reaches the target.')}
    ✅ Optimisation
    ${optimisation?.note || 'Uses tasks loaded in this browser session'}
    ${ragPill(optimisation?.rag || 'neutral', optimisation?.ragLabel || 'Unknown')} ${jumpBtn('Open', 'optimisation')}
    ${tile('Potential uplift (28d)', optimisation?.kpis?.potential ?? '—', optimisation?.deltas?.potential ?? '', optimisation?.dirs?.potential ?? 'flat', 'Sum of estimated extra clicks (28d) across active optimisation tasks (CTR gap logic).')} ${trafficTile('Tasks (All metrics)', optimisation?.kpis?.trafficAll ?? null, 'Worse/Same/Better for active tasks (majority outcome across their objective KPI).')} ${trafficTile('CTR', optimisation?.kpis?.trafficCtr ?? null, 'Worse/Same/Better for tasks whose objective KPI is CTR (28d).')} ${trafficTile('AI citations', optimisation?.kpis?.trafficCitations ?? null, 'Worse/Same/Better for tasks whose objective KPI is AI citations.')}
    📈 Domain Strength
    ${domainStrength?.note || 'Latest snapshot (this month) + competitor gap'}
    ${ragPill(domainStrength?.rag || 'neutral', domainStrength?.ragLabel || 'Unknown')} ${domainStrength?.run ? runBtn(domainStrength.run.label || 'Run snapshot', domainStrength.run.onClick || '', domainStrength.run.title || '') : ''} ${jumpBtn('Open', 'ranking')}
    ${tile('Your score', domainStrength?.kpis?.selfScore ?? '—', domainStrength?.deltas?.selfScore ?? '', domainStrength?.dirs?.selfScore ?? 'flat', 'Your domain strength score (0–100).')} ${tile('Gap to top', domainStrength?.kpis?.gapTop ?? '—', domainStrength?.deltas?.gapTop ?? '', domainStrength?.dirs?.gapTop ?? 'flat', 'Difference between the strongest competitor score and your score (lower is better).')} ${tile('# stronger domains', domainStrength?.kpis?.stronger ?? '—', domainStrength?.deltas?.stronger ?? '', domainStrength?.dirs?.stronger ?? 'flat', 'How many competitors in the snapshot set have a higher score than you.')} ${tile('Rank vs set', domainStrength?.kpis?.rankVs ?? '—', '', 'flat', 'Your rank among the comparison set (you + competitors). Example: 1/16 means strongest in the set.')}
    `; } function renderDashboardGaioPillarsAndRadar(pillars, prevPillars) { const host = document.getElementById('dashboard-gaio-pillars'); if (!host) return; const p = pillars || {}; const prev = prevPillars || {}; const items = [ { key: 'authority', label: 'Authority', score: p.authority, prevScore: prev.authority }, { key: 'contentSchema', label: 'Content / Schema', score: p.contentSchema, prevScore: prev.contentSchema }, { key: 'visibility', label: 'Visibility', score: p.visibility, prevScore: prev.visibility }, { key: 'localEntity', label: 'Local Entity', score: p.localEntity, prevScore: prev.localEntity }, { key: 'serviceArea', label: 'Service Area', score: p.serviceArea, prevScore: prev.serviceArea }, ]; const rowsHtml = items.map(it => { const s = toNum(it.score); const pct = s == null ? 0 : Math.max(0, Math.min(100, s)); const rag = scoreToRag(s, { greenAt: 70, amberAt: 50 }); const delta = formatDeltaNumber(it.score, it.prevScore, { decimals: 0 }); const dir = deltaDir(it.score, it.prevScore, { betterLower: false }); return `
    ${it.label}
    ${s == null ? '—' : Math.round(s)}
    `; }).join(''); host.innerHTML = `
    ${rowsHtml}
    `; // Radar / web diagram const canvas = document.getElementById('dashboard-gaio-radar'); if (!canvas || typeof Chart === 'undefined') return; const ctx = canvas.getContext('2d'); if (!ctx) return; const labels = items.map(i => i.label); const values = items.map(i => (toNum(i.score) ?? 0)); try { if (window.__dashboardGaioRadarChart) { window.__dashboardGaioRadarChart.destroy(); window.__dashboardGaioRadarChart = null; } } catch { // ignore } try { window.__dashboardGaioRadarChart = new Chart(ctx, { type: 'radar', data: { labels, datasets: [{ label: 'Pillars', data: values, borderColor: 'rgba(59, 130, 246, 0.85)', backgroundColor: 'rgba(59, 130, 246, 0.18)', borderWidth: 2, pointBackgroundColor: '#ffffff', pointBorderColor: 'rgba(59, 130, 246, 0.85)', pointRadius: 3 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { r: { min: 0, max: 100, ticks: { display: false }, grid: { color: 'rgba(148, 163, 184, 0.20)' }, angleLines: { color: 'rgba(148, 163, 184, 0.20)' }, pointLabels: { color: 'rgba(226, 232, 240, 0.92)', font: { size: 11, weight: '700' } } } } } }); } catch { // ignore } } async function refreshDashboardDomainStrengthCacheIfNeeded() { const now = Date.now(); // v4: cache key bump to ensure rank/gap are computed against competitor-only set (isCompetitor=true) const cacheV4 = dashboardSafeJsonParse(localStorage.getItem('dashboard_domain_strength_cache_v4'), null); const cacheV3 = dashboardSafeJsonParse(localStorage.getItem('dashboard_domain_strength_cache_v3'), null); const cacheV2 = dashboardSafeJsonParse(localStorage.getItem('dashboard_domain_strength_cache_v2'), null); const cacheV1 = dashboardSafeJsonParse(localStorage.getItem('dashboard_domain_strength_cache_v1'), null); const cache = cacheV4 || cacheV3 || cacheV2 || cacheV1 || null; const lastMs = cache?.fetchedAtMs ? Number(cache.fetchedAtMs) : 0; // If the user has an older cache but not v4 yet, force an upgrade pass const needsUpgradeToV4 = !cacheV4 && (cacheV3 || cacheV2 || cacheV1); const hasUsefulValues = (() => { const selfScore = toNum(cache?.selfScore); const topScore = toNum(cache?.topCompetitorScore); const stronger = toNum(cache?.strongerCount); const competitorsCount = toNum(cache?.competitorsCount); const snapshotDate = cache?.snapshotDate || null; // Treat null-valued caches as stale (common when older code wrote "fresh" cache with nulls). return (typeof selfScore === 'number' || typeof topScore === 'number' || typeof stronger === 'number') && !!snapshotDate && (typeof competitorsCount === 'number'); // needed for rank tile })(); const stale = needsUpgradeToV4 || !lastMs || (now - lastMs) > (6 * 60 * 60 * 1000) || !hasUsefulValues; // 6 hours if (!stale) return { cache, updated: false }; if (window.__dashboardDomainStrengthRefreshing) return { cache, updated: false }; window.__dashboardDomainStrengthRefreshing = true; try { if (typeof fetchDomainStrengthOverview !== 'function') return { cache, updated: false }; const items = await fetchDomainStrengthOverview(); if (!Array.isArray(items) || items.length === 0) return { cache, updated: false }; const self = (typeof getSelfDomainForDomainStrength === 'function') ? getSelfDomainForDomainStrength() : 'alanranger.com'; const normalizedSelf = normalizeDomainForStrength(self); const normItems = items .map(it => ({ domain: normalizeDomainForStrength(it.domain || it.root_domain || it.rootDomain || ''), // API shape: overview items expose metrics under `latest` (but support flat fields too). score: toNum(it?.latest?.score ?? it.score), band: (it?.latest?.band ?? it.band ?? it.label) || null, snapshotDate: it?.latest?.snapshotDate || it.snapshotDate || it.snapshot_date || null, isCompetitor: (it?.isCompetitor === true) || (it?.is_competitor === true) || (it?.domain_type === 'competitor') })) .filter(it => it.domain); const selfItem = normItems.find(it => it.domain === normalizedSelf) || null; // Only compare against explicitly marked competitors const competitors = normItems.filter(it => it.isCompetitor === true && it.domain !== normalizedSelf && typeof it.score === 'number'); const selfScore = selfItem?.score ?? null; const topCompetitor = competitors.slice().sort((a, b) => (b.score ?? -1) - (a.score ?? -1))[0] || null; const topCompetitorScore = topCompetitor?.score ?? null; const strongerCount = (typeof selfScore === 'number') ? competitors.filter(it => typeof it.score === 'number' && it.score > selfScore).length : null; const competitorsCount = competitors.length; // competitor-only denominator const next = { fetchedAtMs: now, snapshotDate: selfItem?.snapshotDate || topCompetitor?.snapshotDate || null, selfDomain: normalizedSelf, selfScore, topCompetitorScore, strongerCount, competitorsCount }; localStorage.setItem('dashboard_domain_strength_cache_v4', JSON.stringify(next)); return { cache: next, updated: true }; } catch { return { cache, updated: false }; } finally { window.__dashboardDomainStrengthRefreshing = false; } } function renderDashboardEeatRadar(eeatSubscores) { const canvas = document.getElementById('dashboard-eeat-radar'); if (!canvas || typeof Chart === 'undefined') return; const ctx = canvas.getContext('2d'); if (!ctx) return; const s = eeatSubscores || {}; const labels = ['Experience', 'Expertise', 'Authoritativeness', 'Trustworthiness']; const values = [ toNum(s.experience) ?? 0, toNum(s.expertise) ?? 0, toNum(s.authoritativeness) ?? 0, toNum(s.trustworthiness) ?? 0 ]; try { if (window.__dashboardEeatRadarChart) { window.__dashboardEeatRadarChart.destroy(); window.__dashboardEeatRadarChart = null; } } catch { // ignore } try { window.__dashboardEeatRadarChart = new Chart(ctx, { type: 'radar', data: { labels, datasets: [{ label: 'EEAT', data: values, borderColor: 'rgba(16, 185, 129, 0.9)', backgroundColor: 'rgba(16, 185, 129, 0.16)', borderWidth: 2, pointBackgroundColor: '#ffffff', pointBorderColor: 'rgba(16, 185, 129, 0.9)', pointRadius: 3 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { r: { beginAtZero: true, min: 0, max: 100, ticks: { display: false }, grid: { color: 'rgba(148,163,184,0.25)' }, angleLines: { color: 'rgba(148,163,184,0.25)' }, pointLabels: { color: 'rgba(226,232,240,0.9)', font: { size: 11, weight: '600' } } } } } }); } catch { // ignore } } function renderDashboardMoneyShareRadar(moneyShareSplit) { const canvas = document.getElementById('dashboard-money-share-radar'); if (!canvas || typeof Chart === 'undefined') return; const ctx = canvas.getContext('2d'); if (!ctx) return; const s = moneyShareSplit || null; const labels = ['Landing', 'Event', 'Product']; const values = [ toNum(s?.landingPct) ?? 0, toNum(s?.eventPct) ?? 0, toNum(s?.productPct) ?? 0 ]; try { if (window.__dashboardMoneyShareRadarChart) { window.__dashboardMoneyShareRadarChart.destroy(); window.__dashboardMoneyShareRadarChart = null; } } catch { // ignore } const title = s ? `Money citations split (of ${s.moneyTotal || 0}): Landing ${s.landing || 0}, Event ${s.event || 0}, Product ${s.product || 0}` : 'Money citations split: run Ranking & AI scan to populate'; canvas.title = title; try { window.__dashboardMoneyShareRadarChart = new Chart(ctx, { type: 'radar', data: { labels, datasets: [{ label: '% of Money citations', data: values, borderColor: 'rgba(245, 158, 11, 0.95)', backgroundColor: 'rgba(245, 158, 11, 0.16)', borderWidth: 2, pointBackgroundColor: '#ffffff', pointBorderColor: 'rgba(245, 158, 11, 0.95)', pointRadius: 3 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { r: { beginAtZero: true, min: 0, max: 100, ticks: { display: false }, grid: { color: 'rgba(148,163,184,0.25)' }, angleLines: { color: 'rgba(148,163,184,0.25)' }, pointLabels: { color: 'rgba(226,232,240,0.9)', font: { size: 11, weight: '600' } } } } } }); } catch { // ignore } } // Helper function to compute dashboard snapshot from audit data function computeDashboardSnapshotFromAuditData(auditData) { if (!auditData) return null; const scores = auditData.scores || {}; const searchData = auditData.searchData || {}; const rankingAiData = auditData.rankingAiData || {}; // Compute GAIO score from pillar scores const pillars = { visibility: scores.visibility || null, authority: typeof scores.authority === 'object' ? scores.authority.score : (scores.authority || null), contentSchema: scores.contentSchema || null, localEntity: scores.localEntity || null, serviceArea: scores.serviceArea || null }; const weights = { visibility: 0.20, authority: 0.30, contentSchema: 0.25, localEntity: 0.15, serviceArea: 0.10 }; let gaioScore = null; if (Object.values(pillars).some(v => typeof v === 'number')) { const weightedSum = (pillars.visibility || 0) * weights.visibility + (pillars.authority || 0) * weights.authority + (pillars.contentSchema || 0) * weights.contentSchema + (pillars.localEntity || 0) * weights.localEntity + (pillars.serviceArea || 0) * weights.serviceArea; gaioScore = Math.round(weightedSum); } // Compute Money Pages aggregate from metrics (needed for Money Pages tile deltas) const moneyMetricsFromAudit = scores.moneyPagesMetrics || auditData.moneyPagesMetrics || null; const moneyAgg = (typeof computeMoneyPagesAggregateFromMetrics === 'function') ? computeMoneyPagesAggregateFromMetrics(moneyMetricsFromAudit) : null; return { gaioScore, aiSummaryScore: scores.aiSummaryScore || (typeof auditData.ai_summary_score === 'number' ? auditData.ai_summary_score : null), pillars, auditKpis: searchData.overview ? { clicks: searchData.overview.clicks || searchData.totalClicks || 0, impressions: searchData.overview.impressions || searchData.totalImpressions || 0, avgPosition: searchData.overview.position || searchData.averagePosition || null, ctrPct: searchData.overview.ctr || searchData.ctr || 0 } : null, moneySharePct: scores.moneyPagesMetrics?.overview ? (scores.moneyPagesMetrics.overview.shareOfImpressions || 0) * 100 : null, moneyAgg: moneyAgg // Add moneyAgg for Money Pages tile deltas }; } // Helper function to fetch previous audit by audit_date for delta calculations async function fetchPreviousAuditForDeltas(propertyUrl, currentAuditDate) { if (!propertyUrl || !currentAuditDate) return null; try { const urlHelper = window.apiUrl || ((path) => { const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; const baseUrl = isLocal ? 'https://ai-geo-audit.vercel.app' : ''; const cleanPath = path.startsWith('/') ? path : `/${path}`; return baseUrl ? `${baseUrl}${cleanPath}` : cleanPath; }); // Fetch audit history to find the previous audit_date // The endpoint returns audits sorted by audit_date ascending const historyResponse = await fetch(urlHelper(`/api/supabase/get-audit-history?propertyUrl=${encodeURIComponent(propertyUrl)}`)); if (!historyResponse.ok) { debugLog(`[Dashboard] Audit history fetch failed: ${historyResponse.status}`, 'warn'); return null; } const historyResult = await historyResponse.json(); if (historyResult.status === 'ok' && Array.isArray(historyResult.data) && historyResult.data.length >= 2) { // Sort audits by date descending (most recent first) const sortedAudits = historyResult.data .map(a => ({ audit_date: a.audit_date || a.auditDate, data: a })) .filter(a => a.audit_date) // Filter out any without dates .sort((a, b) => { const dateA = new Date(a.audit_date); const dateB = new Date(b.audit_date); return dateB - dateA; // Descending order (newest first) }); // Find current audit index const currentIndex = sortedAudits.findIndex(a => a.audit_date === currentAuditDate); // Get previous audit (the one before current) if (currentIndex > 0) { const prevAuditDate = sortedAudits[currentIndex - 1].audit_date; // Fetch full data for previous audit // Note: get-latest-audit doesn't support auditDate param, so we'll use the history data // and compute snapshot from it, or fetch via a different method // For now, we'll compute snapshot from the history data which has the scores const prevAuditFromHistory = sortedAudits[currentIndex - 1].data; // Reconstruct audit data structure from history record const prevAuditData = { auditDate: prevAuditDate, scores: { visibility: prevAuditFromHistory.visibility_score, authority: prevAuditFromHistory.authority_score, contentSchema: prevAuditFromHistory.content_schema_score, localEntity: prevAuditFromHistory.local_entity_score, serviceArea: prevAuditFromHistory.service_area_score, aiSummaryScore: prevAuditFromHistory.ai_summary_score }, searchData: { overview: { clicks: prevAuditFromHistory.gsc_clicks || 0, impressions: prevAuditFromHistory.gsc_impressions || 0, position: prevAuditFromHistory.gsc_avg_position || null, ctr: prevAuditFromHistory.gsc_ctr || 0 } }, moneyPagesMetrics: prevAuditFromHistory.money_pages_metrics || (prevAuditFromHistory.money_pages_summary ? { overview: prevAuditFromHistory.money_pages_summary } : null) }; debugLog(`[Dashboard] Found previous audit_date: ${prevAuditDate} (current: ${currentAuditDate})`, 'info'); return prevAuditData; } else if (currentIndex === -1 && sortedAudits.length > 0) { // Current audit not found in history, use the most recent one as previous const mostRecentDate = sortedAudits[0].audit_date; if (mostRecentDate !== currentAuditDate) { const mostRecentData = sortedAudits[0].data; const prevAuditData = { auditDate: mostRecentDate, scores: { visibility: mostRecentData.visibility_score, authority: mostRecentData.authority_score, contentSchema: mostRecentData.content_schema_score, localEntity: mostRecentData.local_entity_score, serviceArea: mostRecentData.service_area_score, aiSummaryScore: mostRecentData.ai_summary_score }, searchData: { overview: { clicks: mostRecentData.gsc_clicks || 0, impressions: mostRecentData.gsc_impressions || 0, position: mostRecentData.gsc_avg_position || null, ctr: mostRecentData.gsc_ctr || 0 } }, moneyPagesMetrics: mostRecentData.money_pages_metrics || (mostRecentData.money_pages_summary ? { overview: mostRecentData.money_pages_summary } : null) }; debugLog(`[Dashboard] Using most recent audit as previous: ${mostRecentDate}`, 'info'); return prevAuditData; } } } else { debugLog(`[Dashboard] Not enough audit history (found ${historyResult.data?.length || 0} audits, need at least 2)`, 'warn'); } return null; } catch (err) { debugLog(`[Dashboard] Failed to fetch previous audit for deltas: ${err?.message || err}`, 'warn'); return null; } } window.renderDashboardTab = async function renderDashboardTab() { // CRITICAL: Fetch latest audit from Supabase to ensure we have fresh data // This ensures Audit Scan and Money Pages tiles show the latest data after global audit try { const propertyUrl = localStorage.getItem('gsc_property_url') || localStorage.getItem('last_property_url') || ''; if (propertyUrl) { let latestAuditFromSupabase = null; if (typeof window.fetchLatestAuditFromSupabase === 'function') { latestAuditFromSupabase = await window.fetchLatestAuditFromSupabase(propertyUrl, false); } else { // Fallback: fetch directly from API try { const apiBase = window.apiUrl ? window.apiUrl('') : (window.location.origin.includes('localhost') ? 'http://localhost:3000' : 'https://ai-geo-audit.vercel.app'); const response = await fetch(`${apiBase}/api/supabase/get-latest-audit?property_url=${encodeURIComponent(propertyUrl)}`); if (response.ok) { latestAuditFromSupabase = await response.json(); } } catch (apiErr) { debugLog(`[Dashboard] Error fetching latest audit from API: ${apiErr.message || apiErr}`, 'warn'); } } if (latestAuditFromSupabase) { // Update localStorage with latest audit data to ensure snapshot uses fresh data localStorage.setItem('last_audit_results', JSON.stringify(latestAuditFromSupabase)); debugLog(`[Dashboard] Updated localStorage with latest audit from Supabase (audit_date: ${latestAuditFromSupabase.auditDate || latestAuditFromSupabase.audit_date || 'unknown'})`, 'info'); // Also update window.moneyPagesMetrics if available if (latestAuditFromSupabase.scores?.moneyPagesMetrics || latestAuditFromSupabase.moneyPagesMetrics) { window.moneyPagesMetrics = latestAuditFromSupabase.scores?.moneyPagesMetrics || latestAuditFromSupabase.moneyPagesMetrics; window.currentMoneyPagesMetrics = window.moneyPagesMetrics; } } } } catch (fetchErr) { debugLog(`[Dashboard] Error fetching latest audit for dashboard refresh: ${fetchErr.message || fetchErr}`, 'warn'); // Continue with localStorage data as fallback } // Always show LIVE latest-available metrics (from the latest independent audits). // Deltas are computed against the previous audit_date (not global run snapshots). const liveSnapshot = computeDashboardSnapshot(); // If admin key exists, auto-load optimisation tasks so the dashboard isn't blank. // Throttle to avoid hammering the API and to avoid loops (loadAllOptimisationTasks also triggers renderDashboardTab). try { const hasKey = (typeof window.hasAdminKey === 'function') ? window.hasAdminKey() : false; const canLoad = (typeof window.loadAllOptimisationTasks === 'function'); const notLoadedYet = !isOptimisationDataLoadedForDashboard(); const lastAutoMs = window.__dashboardAutoLoadOptimisationMs || 0; const allow = Date.now() - lastAutoMs > 60 * 1000; // 60s throttle if (hasKey && canLoad && notLoadedYet && allow) { window.__dashboardAutoLoadOptimisationMs = Date.now(); setTimeout(() => { try { window.loadAllOptimisationTasks(); } catch {} }, 0); } } catch { // ignore } const runs = getDashboardRuns(); const lastGlobal = runs.length ? runs[runs.length - 1] : null; const prevGlobal = runs.length > 1 ? runs[runs.length - 2] : null; const lastRunEl = document.getElementById('dashboard-last-run'); const lastStatusEl = document.getElementById('dashboard-last-status'); if (lastRunEl) lastRunEl.textContent = lastGlobal ? formatUtcTimestamp(lastGlobal.timestamp) : '—'; if (lastStatusEl) lastStatusEl.textContent = lastGlobal ? (lastGlobal.status || 'ok') : '—'; // CRITICAL: Fetch previous audit by audit_date for proper delta calculations let prevMetricsFromAuditDate = null; try { const currentAudit = dashboardSafeJsonParse(localStorage.getItem('last_audit_results'), null); if (currentAudit && currentAudit.auditDate) { const propertyUrl = localStorage.getItem('gsc_property_url') || localStorage.getItem('last_property_url') || ''; if (propertyUrl) { const prevAuditData = await fetchPreviousAuditForDeltas(propertyUrl, currentAudit.auditDate); if (prevAuditData) { // Compute snapshot from previous audit data const prevSnapshot = computeDashboardSnapshotFromAuditData(prevAuditData); prevMetricsFromAuditDate = prevSnapshot; debugLog(`[Dashboard] Using previous audit_date (${prevAuditData.auditDate}) for delta calculations`, 'info'); } } } } catch (err) { debugLog(`[Dashboard] Error fetching previous audit for deltas: ${err?.message || err}`, 'warn'); } // Use audit_date-based deltas if available, otherwise fall back to global run snapshots const prevMetrics = prevMetricsFromAuditDate || prevGlobal?.metrics || null; const metrics = liveSnapshot; // Dial: GAIO { const v = metrics?.gaioScore ?? null; const p = typeof v === 'number' ? v : null; const rag = scoreToRag(p, { greenAt: 70, amberAt: 50 }); setDial(document.getElementById('dashboard-dial-gaio'), { valueText: typeof v === 'number' ? `${v}/100` : '—', pct: p, rag, deltaText: formatDelta(metrics?.gaioScore, prevMetrics?.gaioScore) }); updateDeltaIndicator('dashboard-delta-gaio', v, prevMetrics?.gaioScore ?? null, { betterLower: false, decimals: 0 }); renderDashboardGaioPillarsAndRadar(metrics?.pillars || null, prevMetrics?.pillars || null); } // Dial: AI Summary { const v = metrics?.aiSummaryScore ?? null; const p = typeof v === 'number' ? v : null; const rag = scoreToRag(p, { greenAt: 70, amberAt: 50 }); setDial(document.getElementById('dashboard-dial-ai-summary'), { valueText: typeof v === 'number' ? `${v}/100` : '—', pct: p, rag, deltaText: formatDelta(metrics?.aiSummaryScore, prevMetrics?.aiSummaryScore) }); updateDeltaIndicator('dashboard-delta-ai-summary', v, prevMetrics?.aiSummaryScore ?? null, { betterLower: false, decimals: 0 }); const host = document.getElementById('dashboard-ai-summary-mini'); if (host) { const markerLeft = (typeof p === 'number') ? Math.max(0, Math.min(100, Math.round(p))) : null; host.innerHTML = `
    ${markerLeft == null ? '' : `
    `}
    Higher = more likely AI systems summarise your brand pages confidently.
    `; } } // Dial: Money share { const pct = metrics?.moneySharePct ?? null; const money = metrics?.moneyCitations ?? null; const total = metrics?.totalCitations ?? null; const rag = scoreToRag(typeof pct === 'number' ? pct : null, { greenAt: 70, amberAt: 50 }); const text = (typeof money === 'number' && typeof total === 'number') ? `${money}/${total} (${pct ?? 0}%)` : '—'; setDial(document.getElementById('dashboard-dial-money-share'), { valueText: text, pct: typeof pct === 'number' ? pct : null, rag, deltaText: formatDelta(metrics?.moneySharePct, prevMetrics?.moneySharePct) }); updateDeltaIndicator('dashboard-delta-money-share', pct, prevMetrics?.moneySharePct ?? null, { betterLower: false, decimals: 0, pp: true }); const host = document.getElementById('dashboard-money-share-mini'); if (host) { const m = (typeof money === 'number') ? money : null; const t = (typeof total === 'number') ? total : null; const share = (m != null && t != null && t > 0) ? Math.max(0, Math.min(1, m / t)) : null; const leftPct = (share == null) ? null : Math.round(share * 100); host.innerHTML = `
    ${leftPct == null ? '' : `
    `}
    Goal: move citations toward Money Pages (commercial intent).
    `; } renderDashboardMoneyShareRadar(metrics?.moneyShareSplit || null); } // Dial: Uplift remaining (Money Pages CTR target) { const extra = metrics?.moneyAgg?.uplift?.extraClicksAbs ?? null; const prevExtra = prevMetrics?.moneyAgg?.uplift?.extraClicksAbs ?? null; const currCtr = metrics?.moneyAgg?.ctrPct ?? null; const targetCtr = metrics?.moneyAgg?.targets?.ctrAbsPct ?? 2.5; const progressPct = (typeof currCtr === 'number' && typeof targetCtr === 'number' && targetCtr > 0) ? Math.max(0, Math.min(100, Math.round((currCtr / targetCtr) * 100))) : null; const rag = scoreToRag(typeof progressPct === 'number' ? progressPct : null, { greenAt: 70, amberAt: 50 }); const valueText = (typeof extra === 'number') ? `+${formatInt(extra)}` : '—'; const deltaText = formatDeltaNumber(extra, prevExtra, { decimals: 0, betterLower: true }); setDial(document.getElementById('dashboard-dial-uplift'), { valueText, pct: typeof progressPct === 'number' ? progressPct : null, rag, deltaText: (typeof extra === 'number' && typeof prevExtra === 'number') ? deltaText : '—' }); updateDeltaIndicator('dashboard-delta-uplift', extra, prevExtra ?? null, { betterLower: true, decimals: 0 }); const host = document.getElementById('dashboard-uplift-mini'); if (host) { const markerLeft = (typeof progressPct === 'number') ? Math.max(0, Math.min(100, Math.round(progressPct))) : null; host.innerHTML = `
    ${markerLeft == null ? '' : `
    `}
    Based on CTR gap: current ${typeof currCtr === 'number' ? formatPct(currCtr, 1) : '—'} vs target ${targetCtr}%.
    `; } } // Dial: EEAT (placeholder) { const v = metrics?.eeatScore ?? null; const p = typeof v === 'number' ? v : null; const rag = scoreToRag(p, { greenAt: 70, amberAt: 50 }); setDial(document.getElementById('dashboard-dial-eeat'), { valueText: typeof v === 'number' ? `${v}/100` : '—', pct: p, rag, deltaText: formatDelta(metrics?.eeatScore, prevMetrics?.eeatScore) }); updateDeltaIndicator('dashboard-delta-eeat', v, prevMetrics?.eeatScore ?? null, { betterLower: false, decimals: 0 }); const host = document.getElementById('dashboard-eeat-mini'); if (host) { const c = metrics?.eeatConfidence || '—'; const s = metrics?.eeatSubscores || {}; const breakdown = `EEAT v1 (proxy) breakdown: Experience: ${toNum(s.experience) ?? '—'}/100 Expertise: ${toNum(s.expertise) ?? '—'}/100 Authoritativeness: ${toNum(s.authoritativeness) ?? '—'}/100 Trustworthiness: ${toNum(s.trustworthiness) ?? '—'}/100 Confidence: ${c} Signals used (where available): reviews+behaviour+local signals, content/schema+snippet readiness, backlinks+domain strength+AI citations, NAP consistency.`; host.innerHTML = `
    Confidence: ${c} · E ${toNum(s.experience) ?? '—'} · Ex ${toNum(s.expertise) ?? '—'} · A ${toNum(s.authoritativeness) ?? '—'} · T ${toNum(s.trustworthiness) ?? '—'}
    `; } renderDashboardEeatRadar(metrics?.eeatSubscores || null); } // Summary cards (use latest available data from localStorage/window) const audit = dashboardSafeJsonParse(localStorage.getItem('last_audit_results'), null); const rankingStore = dashboardSafeJsonParse(localStorage.getItem('rankingAiData'), null); const rankingRows = (rankingStore && Array.isArray(rankingStore.combinedRows)) ? rankingStore.combinedRows : []; const rankingFresh = computeFreshnessBadge(rankingStore?.timestamp || null, { warnDays: 3, staleDays: 7 }); const moneyFresh = computeFreshnessBadge(audit?.timestamp || null, { warnDays: 3, staleDays: 7 }); const gaioRag = scoreToRag(metrics?.gaioScore ?? null, { greenAt: 70, amberAt: 50 }); const moneyShareRag = scoreToRag(metrics?.moneySharePct ?? null, { greenAt: 70, amberAt: 50 }); const auditCard = audit ? { timestamp: audit.timestamp || null, rag: gaioRag, ragLabel: (typeof metrics?.gaioScore === 'number') ? `${gaioRag.toUpperCase()} (${metrics.gaioScore}/100)` : 'Unknown', kpis: audit.searchData ? { clicks: formatInt(metrics?.auditKpis?.clicks), impressions: formatInt(metrics?.auditKpis?.impressions), avgPosition: (typeof metrics?.auditKpis?.avgPosition === 'number') ? formatFixed(metrics.auditKpis.avgPosition, 1) : '—', ctr: (typeof metrics?.auditKpis?.ctrPct === 'number') ? formatPct(metrics.auditKpis.ctrPct, 1) : '—' } : null , deltas: { clicks: (prevMetrics?.auditKpis ? formatDeltaNumber(metrics?.auditKpis?.clicks, prevMetrics.auditKpis.clicks, { decimals: 0 }) : ''), impressions: (prevMetrics?.auditKpis ? formatDeltaNumber(metrics?.auditKpis?.impressions, prevMetrics.auditKpis.impressions, { decimals: 0 }) : ''), avgPosition: (prevMetrics?.auditKpis ? formatDeltaNumber(metrics?.auditKpis?.avgPosition, prevMetrics.auditKpis.avgPosition, { decimals: 1, betterLower: true }) : ''), ctr: (prevMetrics?.auditKpis ? formatDeltaNumber(metrics?.auditKpis?.ctrPct, prevMetrics.auditKpis.ctrPct, { decimals: 1, pp: true }) : '') }, dirs: { clicks: deltaDir(metrics?.auditKpis?.clicks, prevMetrics?.auditKpis?.clicks, { betterLower: false }), impressions: deltaDir(metrics?.auditKpis?.impressions, prevMetrics?.auditKpis?.impressions, { betterLower: false }), avgPosition: deltaDir(metrics?.auditKpis?.avgPosition, prevMetrics?.auditKpis?.avgPosition, { betterLower: true }), ctr: deltaDir(metrics?.auditKpis?.ctrPct, prevMetrics?.auditKpis?.ctrPct, { betterLower: false }) } } : null; const rankingK = metrics?.rankingKpis || null; const prevRankingK = prevMetrics?.rankingKpis || null; const hasRanking = !!(rankingStore && rankingStore.timestamp); const rankingRag = hasRanking ? scoreToRag(rankingK?.top3SharePct ?? null, { greenAt: 70, amberAt: 50 }) : 'neutral'; const rankingCard = { timestamp: rankingStore?.timestamp || null, rag: rankingRag, ragLabel: hasRanking ? (rankingK?.top3SharePct != null ? `Top 3 share: ${rankingK.top3SharePct}%` : 'Rankings loaded') : rankingFresh.label, run: { label: 'Run scan', title: 'Runs the Ranking & AI check now (uses existing keywords list).', onClick: "if (typeof window.dashboardRunRankingAiScan === 'function') window.dashboardRunRankingAiScan();", title: 'Runs the Keyword Ranking and AI check now (uses existing keywords list).' }, kpis: { top3Share: (rankingK?.top3SharePct != null) ? formatPct(rankingK.top3SharePct, 0) : '—', top10Share: (rankingK?.top10SharePct != null) ? formatPct(rankingK.top10SharePct, 0) : '—', citations: formatInt(rankingK?.citationsTotal), moneyShare: (typeof metrics?.moneySharePct === 'number') ? formatPct(metrics.moneySharePct, 0) : '—' }, deltas: { top3Share: (prevRankingK ? formatDeltaNumber(rankingK?.top3SharePct, prevRankingK.top3SharePct, { decimals: 0, pp: true }) : ''), top10Share: (prevRankingK ? formatDeltaNumber(rankingK?.top10SharePct, prevRankingK.top10SharePct, { decimals: 0, pp: true }) : ''), citations: (prevRankingK ? formatDeltaNumber(rankingK?.citationsTotal, prevRankingK.citationsTotal, { decimals: 0 }) : ''), moneyShare: (typeof prevMetrics?.moneySharePct === 'number' ? formatDeltaNumber(metrics?.moneySharePct, prevMetrics.moneySharePct, { decimals: 0, pp: true }) : '') }, dirs: { top3Share: deltaDir(rankingK?.top3SharePct, prevRankingK?.top3SharePct, { betterLower: false }), top10Share: deltaDir(rankingK?.top10SharePct, prevRankingK?.top10SharePct, { betterLower: false }), citations: deltaDir(rankingK?.citationsTotal, prevRankingK?.citationsTotal, { betterLower: false }), moneyShare: deltaDir(metrics?.moneySharePct, prevMetrics?.moneySharePct, { betterLower: false }) } }; const moneyMetricsFromAudit = audit?.scores?.moneyPagesMetrics || audit?.moneyPagesMetrics || null; const moneyAgg = metrics?.moneyAgg || null; const prevMoneyAgg = prevMetrics?.moneyAgg || null; const hasMoneyPages = !!moneyAgg; const moneyCtrTarget = moneyAgg?.targets?.ctrAbsPct ?? 2.5; const moneyCtrProgressPct = (moneyAgg?.ctrPct != null && moneyCtrTarget > 0) ? Math.round((moneyAgg.ctrPct / moneyCtrTarget) * 100) : null; const moneyRag = scoreToRag((typeof moneyCtrProgressPct === 'number') ? Math.min(100, moneyCtrProgressPct) : null, { greenAt: 70, amberAt: 50 }); const moneyPagesCard = { note: hasMoneyPages && audit?.timestamp ? `Last audit: ${formatUtcTimestamp(audit.timestamp)} · CTR target: ≥ ${moneyCtrTarget}% (alt: +0.3pp) · Position targets: ≤10 (alt: -2)` : (audit?.timestamp ? `No Money Pages metrics in latest audit (last: ${formatUtcTimestamp(audit.timestamp)})` : 'No Money Pages metrics yet'), rag: hasMoneyPages ? moneyRag : 'neutral', ragLabel: hasMoneyPages ? `CTR: ${formatPct(moneyAgg?.ctrPct, 1)} (target ${moneyCtrTarget}%)` : 'Not run', run: { label: 'Run scan', title: 'Refreshes Money Pages using the latest audit in Supabase (does not run a new audit).', onClick: "if (typeof window.dashboardRunMoneyPagesScan === 'function') window.dashboardRunMoneyPagesScan();" }, kpis: { clicks: formatInt(moneyAgg?.clicks), ctr: (typeof moneyAgg?.ctrPct === 'number') ? formatPct(moneyAgg.ctrPct, 1) : '—', avgPosition: (typeof moneyAgg?.avgPosition === 'number') ? formatFixed(moneyAgg.avgPosition, 1) : '—', uplift: (typeof moneyAgg?.uplift?.extraClicksAbs === 'number') ? `+${formatInt(moneyAgg.uplift.extraClicksAbs)}` : '—' }, deltas: { clicks: (prevMoneyAgg ? formatDeltaNumber(moneyAgg?.clicks, prevMoneyAgg.clicks, { decimals: 0 }) : ''), ctr: (prevMoneyAgg ? formatDeltaNumber(moneyAgg?.ctrPct, prevMoneyAgg.ctrPct, { decimals: 1, pp: true }) : ''), avgPosition: (prevMoneyAgg ? formatDeltaNumber(moneyAgg?.avgPosition, prevMoneyAgg.avgPosition, { decimals: 1, betterLower: true }) : ''), uplift: (prevMoneyAgg ? formatDeltaNumber(moneyAgg?.uplift?.extraClicksAbs, prevMoneyAgg.uplift?.extraClicksAbs, { decimals: 0, betterLower: true }) : '') }, dirs: { clicks: deltaDir(moneyAgg?.clicks, prevMoneyAgg?.clicks, { betterLower: false }), ctr: deltaDir(moneyAgg?.ctrPct, prevMoneyAgg?.ctrPct, { betterLower: false }), avgPosition: deltaDir(moneyAgg?.avgPosition, prevMoneyAgg?.avgPosition, { betterLower: true }), uplift: deltaDir(moneyAgg?.uplift?.extraClicksAbs, prevMoneyAgg?.uplift?.extraClicksAbs, { betterLower: true }) } }; const optLoaded = isOptimisationDataLoadedForDashboard(); const optPotential = metrics?.optimisationPotentialClicks ?? null; const prevOptPotential = prevMetrics?.optimisationPotentialClicks ?? null; const optScope = window.optimisationModuleState?.scope || 'active_cycle'; const trafficCounts = (optLoaded && typeof computeTrafficLightCounts === 'function') ? computeTrafficLightCounts(getOptimisationTasksForDashboard(), optScope) : null; const optCard = { note: optLoaded ? 'Uses optimisation tasks loaded in this browser session (admin key required)' : 'Not loaded (set admin key + open Optimisation tab or run global run)', rag: optLoaded ? scoreToRag((toNum(metrics?.execPct) ?? null), { greenAt: 70, amberAt: 50 }) : 'neutral', ragLabel: optLoaded ? (typeof metrics?.execPct === 'number' ? `Updated (30d): ${metrics.execPct}%` : 'Loaded') : 'Not loaded', kpis: { potential: (typeof optPotential === 'number') ? `+${formatInt(optPotential)}` : '—', trafficAll: trafficCounts?.all_metrics || null, trafficCtr: trafficCounts?.ctr_28d || null, trafficCitations: trafficCounts?.ai_citations || null }, deltas: { potential: (typeof optPotential === 'number' && typeof prevOptPotential === 'number') ? formatDeltaNumber(optPotential, prevOptPotential, { decimals: 0, betterLower: true }) : '', updated: (typeof metrics?.execPct === 'number' && typeof prevMetrics?.execPct === 'number') ? formatDeltaNumber(metrics.execPct, prevMetrics.execPct, { decimals: 0, pp: true }) : '' }, dirs: { potential: deltaDir(optPotential, prevOptPotential, { betterLower: true }), updated: deltaDir(metrics?.execPct, prevMetrics?.execPct, { betterLower: false }) } }; const ds = metrics?.domainStrength || null; const prevDs = prevMetrics?.domainStrength || null; const selfScore = toNum(ds?.selfScore); const topScore = toNum(ds?.topCompetitorScore); const gapTop = (selfScore != null && topScore != null) ? (topScore - selfScore) : null; const strongerCount = toNum(ds?.strongerCount); const competitorsCount = toNum(ds?.competitorsCount); const rank = (typeof strongerCount === 'number') ? (strongerCount + 1) : null; const rankDenom = (typeof competitorsCount === 'number') ? competitorsCount : null; // competitor-only set const domainRag = scoreToRag(selfScore, { greenAt: 70, amberAt: 50 }); const domainCard = { note: ds?.snapshotDate ? `Snapshot date: ${ds.snapshotDate}` : 'Run snapshot to populate (Ranking & AI tab)', rag: (typeof selfScore === 'number') ? domainRag : 'neutral', ragLabel: (typeof selfScore === 'number') ? `Score: ${formatFixed(selfScore, 1)}` : 'Not run', run: { label: 'Run snapshot', title: 'Runs a Domain Strength snapshot now.', onClick: "if (typeof window.runDomainStrengthSnapshot === 'function') window.runDomainStrengthSnapshot();" }, kpis: { selfScore: (typeof selfScore === 'number') ? formatFixed(selfScore, 1) : '—', gapTop: (typeof gapTop === 'number') ? `+${formatFixed(Math.max(0, gapTop), 1)}` : '—', stronger: (typeof strongerCount === 'number') ? formatInt(strongerCount) : '—', rankVs: (typeof rank === 'number' && typeof rankDenom === 'number') ? `${formatInt(rank)}/${formatInt(rankDenom)}` : '—' }, deltas: { selfScore: (prevDs ? formatDeltaNumber(selfScore, prevDs.selfScore, { decimals: 1 }) : ''), gapTop: (prevDs ? formatDeltaNumber(gapTop, (toNum(prevDs?.topCompetitorScore) != null && toNum(prevDs?.selfScore) != null) ? (toNum(prevDs.topCompetitorScore) - toNum(prevDs.selfScore)) : null, { decimals: 1, betterLower: true }) : ''), stronger: (prevDs ? formatDeltaNumber(strongerCount, prevDs.strongerCount, { decimals: 0, betterLower: true }) : '') }, dirs: { selfScore: deltaDir(selfScore, prevDs?.selfScore, { betterLower: false }), gapTop: deltaDir(gapTop, (toNum(prevDs?.topCompetitorScore) != null && toNum(prevDs?.selfScore) != null) ? (toNum(prevDs.topCompetitorScore) - toNum(prevDs.selfScore)) : null, { betterLower: true }), stronger: deltaDir(strongerCount, prevDs?.strongerCount, { betterLower: true }) } }; renderDashboardSummaryCards({ audit: auditCard, ranking: rankingCard, moneyPages: moneyPagesCard, optimisation: optCard, domainStrength: domainCard }); // Async: refresh Domain Strength cache occasionally so the tile isn't stale. // Keep it non-blocking and avoid loops. setTimeout(() => { refreshDashboardDomainStrengthCacheIfNeeded().then((res) => { if (!res || res.updated !== true) return; // Only re-render if we're still on the dashboard tab. const active = document.querySelector('.aigeo-panel.is-active'); if (active && active.dataset && active.dataset.panel === 'dashboard') { try { window.renderDashboardTab(); } catch {} } }); }, 0); }; function dashboardRunModalSetProgress(pct) { const fill = document.getElementById('dashboardRunProgressFill'); const text = document.getElementById('dashboardRunProgressText'); const p = Math.max(0, Math.min(100, pct)); if (fill) fill.style.width = `${p}%`; if (text) text.textContent = `${p}%`; } function dashboardRunModalSetStep(title, narrative) { const elTitle = document.getElementById('dashboardRunCurrentStep'); const elNarr = document.getElementById('dashboardRunNarrative'); if (elTitle) elTitle.textContent = title || ''; if (elNarr) elNarr.textContent = narrative || ''; } function dashboardRunModalRenderSteps(steps) { const list = document.getElementById('dashboardRunStepsList'); if (!list) return; list.innerHTML = ''; steps.forEach(s => { const row = document.createElement('div'); row.style.display = 'flex'; row.style.justifyContent = 'space-between'; row.style.gap = '0.75rem'; row.style.padding = '0.5rem 0.75rem'; row.style.border = '1px solid var(--dark-border)'; row.style.borderRadius = '8px'; row.style.background = 'rgba(15, 20, 25, 0.35)'; const left = document.createElement('div'); left.textContent = s.label; left.style.fontWeight = '700'; const right = document.createElement('div'); right.textContent = s.status; right.style.fontWeight = '800'; right.style.color = s.status === 'Done' ? '#10b981' : (s.status === 'Running' ? '#f59e0b' : (s.status === 'Failed' ? '#ef4444' : 'var(--dark-text-muted)')); row.appendChild(left); row.appendChild(right); list.appendChild(row); }); } function showDashboardRunModal() { const modal = document.getElementById('dashboardRunModal'); const closeBtn = document.getElementById('dashboardRunClose'); const summary = document.getElementById('dashboardRunSummary'); if (summary) summary.style.display = 'none'; if (modal) modal.style.display = 'block'; if (closeBtn) closeBtn.disabled = true; dashboardRunModalSetProgress(0); } function finishDashboardRunModal({ ok, summaryHtml }) { const closeBtn = document.getElementById('dashboardRunClose'); const summary = document.getElementById('dashboardRunSummary'); const content = document.getElementById('dashboardRunSummaryContent'); if (content) content.innerHTML = summaryHtml || ''; if (summary) summary.style.display = 'block'; if (closeBtn) { closeBtn.disabled = false; closeBtn.style.opacity = '1'; closeBtn.title = 'Close'; } } window.runDashboardGlobalRun = async function runDashboardGlobalRun() { const runBtn = document.getElementById('dashboard-run-all-btn'); if (runBtn) runBtn.disabled = true; // Warn if admin key missing (task update step will likely fail) if (typeof window.hasAdminKey === 'function' && !window.hasAdminKey()) { const proceed = confirm('Admin key is not set. The "Update All Tasks" step will likely fail.\n\nRun anyway?'); if (!proceed) { if (runBtn) runBtn.disabled = false; return; } } const startIso = new Date().toISOString(); const stepDefs = [ { key: 'sync_csv', label: 'Sync CSV', runner: async () => { if (typeof window.syncCsv === 'function') await window.syncCsv(); } }, { key: 'audit_scan', label: 'Run Audit Scan', runner: async () => { if (typeof window.runAudit === 'function') await window.runAudit(); } }, { key: 'ranking_ai', label: 'Run Ranking & AI Scan', runner: async () => { if (typeof window.loadRankingAiData === 'function') { await window.loadRankingAiData(true); // Ensure Ranking & AI tab is refreshed after data loads if (typeof window.renderRankingAiTab === 'function') { await new Promise(resolve => setTimeout(resolve, 500)); // Small delay to ensure data is set window.renderRankingAiTab(); } } } }, { key: 'money_pages', label: 'Run Money Pages Scan', runner: async () => { if (typeof window.dashboardRunMoneyPagesScan === 'function') { await window.dashboardRunMoneyPagesScan(); } } }, { key: 'domain_strength', label: 'Run Domain Strength Snapshot', runner: async () => { if (typeof window.runDomainStrengthSnapshot === 'function') await window.runDomainStrengthSnapshot(); } }, { key: 'update_tasks', label: 'Update All Tasks with Latest Data', runner: async () => { // Wait a moment to ensure audit data is fully saved from previous steps await new Promise(resolve => setTimeout(resolve, 2000)); // Verify audit data is available (needed for URL-based tasks) const auditData = dashboardSafeJsonParse(localStorage.getItem('last_audit_results'), null); if (!auditData || !auditData.searchData) { debugLog('[Global Run] Audit data not yet available, but continuing with task update...', 'warn'); } else { debugLog(`[Global Run] Audit data found: hasSearchData=${!!auditData.searchData}, hasMoneyPagesMetrics=${!!(auditData.scores?.moneyPagesMetrics)}, queryTotalsCount=${auditData.searchData?.queryTotals?.length || 0}`, 'info'); } // Ensure tasks are actually loaded before/after bulk update so the dashboard reflects real outcomes. if (typeof window.loadAllOptimisationTasks === 'function') { try { await window.loadAllOptimisationTasks(); // Wait for tasks to fully load await new Promise(resolve => setTimeout(resolve, 1000)); // Verify tasks are loaded if (!window.optimisationModuleState || !window.optimisationModuleState.allTasks) { throw new Error('Tasks failed to load - optimisationModuleState.allTasks is empty'); } } catch (err) { debugLog(`[Global Run] Failed to load tasks: ${err?.message || err}`, 'error'); throw new Error(`Failed to load optimisation tasks: ${err?.message || err}`); } } else { throw new Error('loadAllOptimisationTasks function not available'); } if (typeof window.bulkUpdateAllTasks === 'function') { try { await window.bulkUpdateAllTasks(); } catch (err) { debugLog(`[Global Run] Bulk update failed: ${err?.message || err}`, 'error'); throw new Error(`Bulk update failed: ${err?.message || err}`); } } else { throw new Error('bulkUpdateAllTasks function not available'); } // Reload tasks to reflect updates if (typeof window.loadAllOptimisationTasks === 'function') { try { await window.loadAllOptimisationTasks(); } catch (err) { debugLog(`[Global Run] Failed to reload tasks after update: ${err?.message || err}`, 'warn'); } } } } ]; const steps = stepDefs.map(s => ({ key: s.key, label: s.label, status: 'Pending' })); const results = { ok: true, errors: [] }; showDashboardRunModal(); dashboardRunModalRenderSteps(steps); const closeBtn = document.getElementById('dashboardRunClose'); if (closeBtn) { closeBtn.onclick = () => { const modal = document.getElementById('dashboardRunModal'); if (modal) modal.style.display = 'none'; }; } for (let i = 0; i < stepDefs.length; i++) { const s = stepDefs[i]; steps[i].status = 'Running'; dashboardRunModalRenderSteps(steps); dashboardRunModalSetProgress(Math.round((i / stepDefs.length) * 100)); dashboardRunModalSetStep(s.label, 'Working…'); try { await s.runner(); steps[i].status = 'Done'; } catch (err) { steps[i].status = 'Failed'; results.ok = false; results.errors.push({ step: s.key, message: err?.message || String(err) }); } dashboardRunModalRenderSteps(steps); } dashboardRunModalSetProgress(100); dashboardRunModalSetStep('Done', results.ok ? 'All steps completed.' : 'Completed with errors.'); // Refresh optimisation tasks so execution dial reflects latest measurements try { if (typeof window.loadAllOptimisationTasks === 'function') { await window.loadAllOptimisationTasks(); // Small delay to ensure tasks are fully processed await new Promise(resolve => setTimeout(resolve, 500)); } } catch { // ignore } const snapshot = computeDashboardSnapshot(); const runRecord = { timestamp: startIso, status: results.ok ? 'ok' : 'partial', steps, metrics: snapshot }; saveDashboardRun(runRecord); // Wait a moment to ensure audit data is fully saved to Supabase before refreshing dashboard await new Promise(resolve => setTimeout(resolve, 2000)); // Refresh all tabs to ensure they show fresh data // CRITICAL: Await renderDashboardTab to ensure it fetches latest audit from Supabase if (typeof window.renderDashboardTab === 'function') { try { await window.renderDashboardTab(); } catch (e) { debugLog(`[Global Run] Failed to refresh Dashboard tab: ${e?.message || e}`, 'warn'); } } // Explicitly refresh other module tabs to ensure they're updated if (typeof window.renderRankingAiTab === 'function') { try { window.renderRankingAiTab(); } catch (e) { debugLog(`[Global Run] Failed to refresh Ranking & AI tab: ${e?.message || e}`, 'warn'); } } // Money Pages tab is refreshed by dashboardRunMoneyPagesScan, but ensure it's rendered if needed if (typeof window.renderMoneyPagesSection === 'function' && window.currentMoneyPagesMetrics) { try { await window.renderMoneyPagesSection(window.currentMoneyPagesMetrics); if (typeof window.wireTopLevelFilter === 'function') window.wireTopLevelFilter(); if (typeof window.wireMoneyKpiMetricSelector === 'function') window.wireMoneyKpiMetricSelector(); if (typeof window.__initMoneyPagesPanelAfterRender === 'function') window.__initMoneyPagesPanelAfterRender(); } catch (e) { debugLog(`[Global Run] Failed to refresh Money Pages tab: ${e?.message || e}`, 'warn'); } } const summaryHtml = results.ok ? `
    Saved a new global run snapshot.
    ` : `
    Saved a partial global run snapshot.
    Errors: ${results.errors.map(e => `${e.step}: ${e.message}`).join(' | ')}
    `; finishDashboardRunModal({ ok: results.ok, summaryHtml }); if (runBtn) runBtn.disabled = false; }; window.dashboardRunRankingAiScan = async function dashboardRunRankingAiScan() { try { if (typeof window.loadRankingAiData !== 'function') { alert('Ranking & AI scan is not available on this page.'); return; } await window.loadRankingAiData(true); if (typeof window.renderDashboardTab === 'function') window.renderDashboardTab(); } catch (e) { alert(`Ranking & AI scan failed: ${e?.message || e}`); } }; window.dashboardRunMoneyPagesScan = async function dashboardRunMoneyPagesScan() { try { const propertyUrl = (window.getPropertyUrl ? window.getPropertyUrl() : '') || localStorage.getItem('gsc_property_url') || localStorage.getItem('last_property_url') || ''; if (!propertyUrl) { alert('Property URL not set. Go to Configuration & Reporting and set it first.'); return; } // Reuse the existing Supabase fetch helper (loads timestamp first, then full data). const latest = await fetchLatestAuditFromSupabase(propertyUrl, false); if (!latest) { alert('No latest audit found in Supabase for this property.'); return; } // Store as current audit snapshot (so Dashboard cards have a timestamp baseline). try { localStorage.setItem('last_audit_results', JSON.stringify(latest)); } catch (storageErr) { // Non-fatal: continue without localStorage. } const metrics = latest?.scores?.moneyPagesMetrics || latest?.moneyPagesMetrics || null; window.currentMoneyPagesMetrics = metrics; window.moneyPagesMetrics = metrics; // If Money Pages panel exists and renderer is available, rebuild it now. if (typeof window.renderMoneyPagesSection === 'function') { await window.renderMoneyPagesSection(metrics); if (typeof window.wireTopLevelFilter === 'function') window.wireTopLevelFilter(); if (typeof window.wireMoneyKpiMetricSelector === 'function') window.wireMoneyKpiMetricSelector(); if (typeof window.__initMoneyPagesPanelAfterRender === 'function') window.__initMoneyPagesPanelAfterRender(); } if (typeof window.renderDashboardTab === 'function') window.renderDashboardTab(); } catch (e) { alert(`Money Pages refresh failed: ${e?.message || e}`); } }; window.addEventListener('DOMContentLoaded', () => { const btn = document.getElementById('dashboard-run-all-btn'); if (btn) { btn.addEventListener('click', () => { if (typeof window.runDashboardGlobalRun === 'function') window.runDashboardGlobalRun(); }); } const closeBtn = document.getElementById('dashboardRunClose'); if (closeBtn) { closeBtn.onclick = () => { const modal = document.getElementById('dashboardRunModal'); if (modal) modal.style.display = 'none'; }; } if (typeof window.renderDashboardTab === 'function') { window.renderDashboardTab(); } });